diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..105c76b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,28 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +ij_visual_guides = 120 + +[*.bat] +end_of_line = crlf + +[*.{kt,kts,java}] +charset = utf-8 +indent_style = space +indent_size = 4 +max_line_length = 200 # Prefer manual wrapping +trim_trailing_whitespace = true +ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true +ij_kotlin_line_break_after_multiline_when_entry = false +ij_kotlin_name_count_to_use_star_import = 999 +ij_kotlin_name_count_to_use_star_import_for_members = 999 +ij_kotlin_packages_to_use_import_on_demand = + +[*.json] +charset = utf-8 +indent_style = space +indent_size = 2 diff --git a/.github/ISSUE_TEMPLATE/bug-report.yaml b/.github/ISSUE_TEMPLATE/bug-report.yaml new file mode 100644 index 0000000..1858a3d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yaml @@ -0,0 +1,83 @@ +name: Bug report +description: Create a bug report +labels: [ bug ] +body: + - type: textarea + id: description + attributes: + label: Describe the bug + description: | + A clear and concise description of the bug, including steps to reproduce it and the normally expected behavior. + You can also attach screenshots or screen recordings to help explain your issue. + placeholder: | + 1. Go to … + 2. Click on … + 3. Scroll down to … + 4. See error / the app crashes + + Instead, I expect … + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs + description: | + Please paste your client logs (logcat) here, *NOT* server logs (in most cases). + Learn how to capture those logcats [here](https://wiki.lineageos.org/logcat.html). + Make sure that they don't contain any sensitive information like server URL, auth tokens or passwords. + placeholder: Paste logs… + render: shell + - type: input + id: app-version + attributes: + label: Application version + description: The version of the installed Jellyfin Android app. + placeholder: 2.3.0 + validations: + required: true + - type: dropdown + id: installation-source + attributes: + label: Where did you install the app from? + description: Choose the appropriate app store or installation method. + options: + - Google Play + - F-Droid + - Amazon Appstore + - Sideloaded APK (libre build) + - Sideloaded APK (proprietary build) + - type: input + id: device-info + attributes: + label: Device information + description: Manufacturer, model + placeholder: Google Pixel 5, Samsung Galaxy S21 + validations: + required: true + - type: input + id: android-version + attributes: + label: Android version + description: Version of the OS and other information (e.g. custom ROM / OEM skin) + placeholder: Android 11, LineageOS 18.1 + validations: + required: true + - type: input + id: server-version + attributes: + label: Jellyfin server version + description: If on unstable, please specify the commit hash. + placeholder: 10.7.6 + validations: + required: true + - type: checkboxes + id: video-player + attributes: + label: Which video player implementations does this bug apply to? + description: | + *If applicable.* Video player can be switched in the client settings of the app. + options: + - label: Web player (default) + - label: Integrated player (ExoPlayer) + - label: External player (VLC, mpv, MX Player) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..e516d32 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Jellyfin documentation + url: https://jellyfin.org/docs/getting-help.html + about: Our documentation contains lots of help for common issues diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..ed9581d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,13 @@ +--- +name: Feature Request +about: Request a new feature +title: '' +labels: enhancement +assignees: '' +--- + +**Describe the feature you'd like** + + +**Additional context** + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..dd411ca --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ + + +**Changes** + + +**Issues** + diff --git a/.github/workflows/app-build.yaml b/.github/workflows/app-build.yaml new file mode 100644 index 0000000..a1681c4 --- /dev/null +++ b/.github/workflows/app-build.yaml @@ -0,0 +1,36 @@ +name: App / Build + +on: + push: + branches: + - master + pull_request: + +permissions: + contents: read + +jobs: + build: + name: Build + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - name: Setup Java + uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1 + with: + distribution: temurin + java-version: 17 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@dbbdc275be76ac10734476cc723d82dfe7ec6eda # v3.4.2 + - name: Assemble debug APKs + run: ./gradlew assembleDebug + - name: Create publish bundle + run: mkdir -p build/gh-app-publish/; find app/build/ -iname "*.apk" -exec mv "{}" build/gh-app-publish/ \; + - name: Upload artifacts + uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4 + with: + name: build-artifacts + retention-days: 14 + if-no-files-found: error + path: build/gh-app-publish/ diff --git a/.github/workflows/app-lint.yaml b/.github/workflows/app-lint.yaml new file mode 100644 index 0000000..55d5525 --- /dev/null +++ b/.github/workflows/app-lint.yaml @@ -0,0 +1,38 @@ +name: App / Lint + +on: + push: + branches: + - master + - release-* + pull_request: + +permissions: + contents: read + security-events: write + +jobs: + lint: + name: Lint + runs-on: ubuntu-22.04 + continue-on-error: true + strategy: + matrix: + task: [ detekt, lint ] + steps: + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - name: Setup Java + uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1 + with: + distribution: temurin + java-version: 17 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@dbbdc275be76ac10734476cc723d82dfe7ec6eda # v3.4.2 + - name: Run ${{ matrix.task }} task + run: ./gradlew ${{ matrix.task }} + - name: Upload SARIF files + uses: github/codeql-action/upload-sarif@b611370bb5703a7efb587f9d136a52ea24c5c38c # v3.25.11 + if: ${{ always() }} + with: + sarif_file: . diff --git a/.github/workflows/app-publish.yaml b/.github/workflows/app-publish.yaml new file mode 100644 index 0000000..e409c1b --- /dev/null +++ b/.github/workflows/app-publish.yaml @@ -0,0 +1,102 @@ +name: App / Publish + +on: + push: + tags: + - v* + +jobs: + publish: + name: Publish + runs-on: ubuntu-22.04 + if: ${{ contains(github.repository_owner, 'jellyfin') }} + steps: + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - name: Setup Java + uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1 + with: + distribution: temurin + java-version: 17 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@dbbdc275be76ac10734476cc723d82dfe7ec6eda # v3.4.2 + - name: Set JELLYFIN_VERSION + run: echo "JELLYFIN_VERSION=$(echo ${GITHUB_REF#refs/tags/v} | tr / -)" >> $GITHUB_ENV + - name: Assemble release files + run: ./gradlew assemble bundleProprietaryRelease versionTxt + - name: Sign libre APK + id: libreSign + uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # tag=v1 + env: + BUILD_TOOLS_VERSION: "34.0.0" + with: + releaseDirectory: app/build/outputs/apk/libre/release + signingKeyBase64: ${{ secrets.KEYSTORE }} + keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} + alias: ${{ secrets.KEY_ALIAS }} + keyPassword: ${{ secrets.KEY_PASSWORD }} + - name: Sign proprietary APK + id: proprietarySign + uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # tag=v1 + env: + BUILD_TOOLS_VERSION: "34.0.0" + with: + releaseDirectory: app/build/outputs/apk/proprietary/release + signingKeyBase64: ${{ secrets.KEYSTORE }} + keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} + alias: ${{ secrets.KEY_ALIAS }} + keyPassword: ${{ secrets.KEY_PASSWORD }} + - name: Sign proprietary app bundle + id: proprietaryBundleSign + uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # tag=v1 + env: + BUILD_TOOLS_VERSION: "34.0.0" + with: + releaseDirectory: app/build/outputs/bundle/proprietaryRelease + signingKeyBase64: ${{ secrets.KEYSTORE }} + keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} + alias: ${{ secrets.KEY_ALIAS }} + keyPassword: ${{ secrets.KEY_PASSWORD }} + - name: Prepare release archive + run: | + mkdir -p build/jellyfin-publish + mv app/build/outputs/apk/*/*/jellyfin-android-*-libre-debug.apk build/jellyfin-publish/ + mv app/build/outputs/apk/*/*/jellyfin-android-*-proprietary-debug.apk build/jellyfin-publish/ + mv app/build/outputs/apk/*/*/jellyfin-android-*-libre-release-unsigned.apk build/jellyfin-publish/ + mv app/build/outputs/apk/*/*/jellyfin-android-*-proprietary-release-unsigned.apk build/jellyfin-publish/ + mv ${{ steps.libreSign.outputs.signedReleaseFile }} build/jellyfin-publish/jellyfin-android-v${{ env.JELLYFIN_VERSION }}-libre-release.apk + mv ${{ steps.proprietarySign.outputs.signedReleaseFile }} build/jellyfin-publish/jellyfin-android-v${{ env.JELLYFIN_VERSION }}-proprietary-release.apk + mv ${{ steps.proprietaryBundleSign.outputs.signedReleaseFile }} build/jellyfin-publish/jellyfin-android-v${{ env.JELLYFIN_VERSION }}-proprietary-release.aab + mv app/build/version.txt build/jellyfin-publish/ + - name: Upload release archive to GitHub release + uses: alexellis/upload-assets@13926a61cdb2cb35f5fdef1c06b8b591523236d3 # 0.4.1 + env: + GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }} + with: + asset_paths: '["build/jellyfin-publish/*"]' + - name: Upload release archive to repo.jellyfin.org + uses: burnett01/rsync-deployments@796cf0d5e4b535745ce49d7429f77cf39e25ef39 # 7.0.1 + with: + switches: -vrptz + path: build/jellyfin-publish/ + remote_path: /srv/incoming/android/v${{ env.JELLYFIN_VERSION }}/ + remote_host: ${{ secrets.REPO_HOST }} + remote_user: ${{ secrets.REPO_USER }} + remote_key: ${{ secrets.REPO_KEY }} + - name: Update repo.jellyfin.org symlinks + uses: appleboy/ssh-action@029f5b4aeeeb58fdfe1410a5d17f967dacf36262 # v1.0.3 + with: + host: ${{ secrets.REPO_HOST }} + username: ${{ secrets.REPO_USER }} + key: ${{ secrets.REPO_KEY }} + envs: JELLYFIN_VERSION + script_stop: true + script: | + if [ -d "/srv/repository/main/client/android/versions/v${{ env.JELLYFIN_VERSION }}" ] && [ -n "${{ env.JELLYFIN_VERSION }}" ]; then + sudo rm -r /srv/repository/main/client/android/versions/v${{ env.JELLYFIN_VERSION }} + fi + sudo mv /srv/incoming/android/v${{ env.JELLYFIN_VERSION }} /srv/repository/main/client/android/versions/v${{ env.JELLYFIN_VERSION }} + cd /srv/repository/main/client/android; + sudo rm -rf *.apk version.txt; + sudo ln -s versions/v${JELLYFIN_VERSION}/jellyfin-android-v${JELLYFIN_VERSION}-*.apk .; + sudo ln -s versions/v${JELLYFIN_VERSION}/version.txt .; diff --git a/.github/workflows/app-test.yaml b/.github/workflows/app-test.yaml new file mode 100644 index 0000000..205d813 --- /dev/null +++ b/.github/workflows/app-test.yaml @@ -0,0 +1,28 @@ +name: App / Test + +on: + push: + branches: + - master + - release-* + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - name: Setup Java + uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1 + with: + distribution: temurin + java-version: 17 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@dbbdc275be76ac10734476cc723d82dfe7ec6eda # v3.4.2 + - name: Run test task + run: ./gradlew test diff --git a/.github/workflows/gradlew-validate.yaml b/.github/workflows/gradlew-validate.yaml new file mode 100644 index 0000000..0d8e12e --- /dev/null +++ b/.github/workflows/gradlew-validate.yaml @@ -0,0 +1,22 @@ +name: Gradle / Validate wrapper + +on: + push: + branches: + - master + pull_request: + paths: + - '**/gradlе-wrapper.jar' + +permissions: + contents: read + +jobs: + validate: + name: Validate + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - name: Validate Gradle Wrapper + uses: gradle/actions/wrapper-validation@dbbdc275be76ac10734476cc723d82dfe7ec6eda # v3.4.2 diff --git a/.github/workflows/repo-merge-conflict.yaml b/.github/workflows/repo-merge-conflict.yaml new file mode 100644 index 0000000..4621c79 --- /dev/null +++ b/.github/workflows/repo-merge-conflict.yaml @@ -0,0 +1,18 @@ +name: Repo / Label merge conflict + +on: + push: + pull_request_target: + types: + - synchronize + +jobs: + triage: + name: Triage + runs-on: ubuntu-22.04 + if: ${{ contains(github.repository_owner, 'jellyfin') }} + steps: + - uses: eps1lon/actions-label-merge-conflict@1b1b1fcde06a9b3d089f3464c96417961dde1168 # v3.0.2 + with: + dirtyLabel: merge conflict + repoToken: ${{ secrets.JF_BOT_TOKEN }} diff --git a/.github/workflows/repo-milestone.yaml b/.github/workflows/repo-milestone.yaml new file mode 100644 index 0000000..7f9beaf --- /dev/null +++ b/.github/workflows/repo-milestone.yaml @@ -0,0 +1,61 @@ +name: Repo / Auto-apply milestone + +on: + pull_request_target: + types: + - opened + - reopened + +jobs: + milestone: + name: Apply milestone + runs-on: ubuntu-22.04 + if: ${{ contains(github.repository_owner, 'jellyfin') }} + steps: + - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + github-token: ${{ secrets.JF_BOT_TOKEN }} + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const pr_id = context.issue.number; + + let response = await github.rest.issues.get({ + owner, + repo, + issue_number: pr_id, + }); + const pr = response.data; + + if (pr.milestone !== null) { + return; + } + + response = await github.rest.issues.listMilestones({ + owner, + repo, + state: 'open', + }); + const milestones = response.data; + + // Find first open milestone + const milestone = milestones.reduce( + (prev, current) => prev?.number < current.number ? prev : current, + null, + ); + + if (milestone === null) { + console.warn('No suitable milestone found, aborting.'); + return; + } + + console.log(`Found milestone to apply: ${milestone.title}`); + + await github.rest.issues.update({ + owner, + repo, + issue_number: pr_id, + milestone: milestone.number, + }); + + console.log(`Successfully applied milestone ${milestone.title} to PR #${pr_id}`); diff --git a/.github/workflows/repo-stale.yaml b/.github/workflows/repo-stale.yaml new file mode 100644 index 0000000..45e34c3 --- /dev/null +++ b/.github/workflows/repo-stale.yaml @@ -0,0 +1,32 @@ +name: Repo / Reply stale issue + +on: + schedule: + - cron: '30 3 * * *' + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +jobs: + triage: + name: Triage + runs-on: ubuntu-22.04 + if: ${{ contains(github.repository_owner, 'jellyfin') }} + steps: + - uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0 + with: + repo-token: ${{ secrets.JF_BOT_TOKEN }} + days-before-stale: 120 + days-before-pr-stale: -1 + days-before-close: 21 + days-before-pr-close: -1 + exempt-issue-labels: enhancement,confirmed + stale-issue-label: stale + stale-issue-message: |- + This issue has gone 120 days without comment. To avoid abandoned issues, it will be closed in 21 days if there are no new comments. + + If you're the original submitter of this issue, please comment confirming if this issue still affects you in the latest release or master branch, or close the issue if it has been fixed. If you're another user also affected by this bug, please comment confirming so. Either action will remove the stale label. + + This bot exists to prevent issues from becoming stale and forgotten. Jellyfin is always moving forward, and bugs are often fixed as side effects of other changes. We therefore ask that bug report authors remain vigilant about their issues to ensure they are closed if fixed, or re-confirmed - perhaps with fresh logs or reproduction examples - regularly. If you have any questions you can reach us on [Matrix or Social Media](https://docs.jellyfin.org/general/getting-help.html). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..044b20a --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +build +/captures +.externalNativeBuild +.cxx \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..4522ba0 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,339 @@ +GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + {{description}} + Copyright (C) {{year}} {{fullname}} + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + {signature of Ty Coon}, 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.md b/README.md index 65b77b4..5bcae31 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,97 @@ -# jellyfin +

Jellyfin Android

+

Part of the Jellyfin Project

-Streaming Client for Android \ No newline at end of file +--- + +

+Logo Banner +
+
+ +GPL 2.0 License + + +Current Release + + +Translation Status + +
+ +Donate + + +Feature Requests + + +Chat on Matrix + + +Join our Subreddit + +
+ +Jellyfin on Google Play + + +Jellyfin on Amazon Appstore + + +Jellyfin on F-Droid + +
+Download archive +

+ +Jellyfin Mobile is an Android app that connects to Jellyfin instances and integrates with the [official web client](https://github.com/jellyfin/jellyfin-web). +We welcome all contributions and pull requests! If you have a larger feature in mind please open an issue so we can discuss the implementation before you start. +Even though the client is only a web wrapper there are still lots of improvements and bug fixes that can be accomplished with Android and Kotlin knowledge. + +Most of the translations can be found in the [web client](https://translate.jellyfin.org/projects/jellyfin/jellyfin-web) since it's the base for the Android client as well. Translations for the app can also be improved very easily from our [Weblate](https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-android) instance. Look through the following graphic to see if your native language could use some work! + + +Detailed Translation Status + + +This client was rewritten from scratch with a fresh git history in July to August 2020, and replaces the old Cordova-based client, +which can still be found [in the archives](https://github.com/jellyfin-archive/jellyfin-android-original). + +## Build Process + +### Dependencies + +- Android SDK + +### Build + +1. Clone or download this repository + + ```sh + git clone https://github.com/jellyfin/jellyfin-android.git + cd jellyfin-android + ``` + +2. Open the project in Android Studio and run it from there or build an APK directly through Gradle: + + ```sh + ./gradlew assembleDebug + ``` + +### Deploy to device/emulator + + ```sh + ./gradlew installDebug + ``` + +*You can also replace the "Debug" with "Release" to get an optimized release binary.* + +## Release Flavors + +There are two flavors (variants) of the Jellyfin Android app: + +- The **proprietary** version comes with Google Chromecast support +- The **libre** version comes without Google Chromecast support + +The proprietary version is available on [Google Play](https://play.google.com/store/apps/details?id=org.jellyfin.mobile) and the [Amazon Appstore](https://www.amazon.com/gp/aw/d/B081RFTTQ9), while the libre version is available on [F-Droid](https://f-droid.org/en/packages/org.jellyfin.mobile/). +Additionally, `beta` releases exist for both flavors, but only the proprietary version is published to a beta track on [Google Play](https://play.google.com/store/apps/details?id=org.jellyfin.mobile). +If you'd like to test the beta outside of Google Play, you can simply download it from the [GitHub releases](https://github.com/jellyfin/jellyfin-android/releases/latest). diff --git a/android-lint.xml b/android-lint.xml new file mode 100644 index 0000000..e20f8e8 --- /dev/null +++ b/android-lint.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..5771585 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,213 @@ +import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + alias(libs.plugins.android.app) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.ksp) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.detekt) + alias(libs.plugins.android.junit5) +} + +detekt { + buildUponDefaultConfig = true + allRules = false + config = files("${rootProject.projectDir}/detekt.yml") + autoCorrect = true +} + +android { + namespace = "org.jellyfin.mobile" + compileSdk = 34 + + defaultConfig { + minSdk = 21 + targetSdk = 34 + versionName = project.getVersionName() + versionCode = getVersionCode(versionName!!) + setProperty("archivesBaseName", "jellyfin-android-v$versionName") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables.useSupportLibrary = true + } + + val releaseSigningConfig = SigningHelper.loadSigningConfig(project)?.let { config -> + signingConfigs.create("release") { + storeFile = config.storeFile + storePassword = config.storePassword + keyAlias = config.keyAlias + keyPassword = config.keyPassword + } + } + + buildTypes { + getByName("release") { + isMinifyEnabled = true + isShrinkResources = true + aaptOptions.cruncherEnabled = false + + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + signingConfig = releaseSigningConfig + } + getByName("debug") { + applicationIdSuffix = ".debug" + isDebuggable = true + aaptOptions.cruncherEnabled = false + } + } + + flavorDimensions += "variant" + productFlavors { + register("libre") { + dimension = "variant" + buildConfigField("boolean", "IS_PROPRIETARY", "false") + } + register("proprietary") { + dimension = "variant" + buildConfigField("boolean", "IS_PROPRIETARY", "true") + isDefault = true + } + } + + bundle { + language { + enableSplit = false + } + } + + @Suppress("UnstableApiUsage") + buildFeatures { + buildConfig = true + viewBinding = true + compose = true + } + kotlinOptions { + @Suppress("SuspiciousCollectionReassignment") + freeCompilerArgs += listOf("-Xopt-in=kotlin.RequiresOptIn") + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + isCoreLibraryDesugaringEnabled = true + } + lint { + lintConfig = file("$rootDir/android-lint.xml") + abortOnError = false + sarifReport = true + } +} + +ksp { + arg("room.schemaLocation", "$projectDir/schemas") + arg("room.incremental", "true") +} + +dependencies { + val proprietaryImplementation by configurations + + // Kotlin + implementation(libs.bundles.coroutines) + + // Core + implementation(libs.bundles.koin) + implementation(libs.androidx.core) + implementation(libs.androidx.core.splashscreen) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.activity) + implementation(libs.androidx.fragment) + coreLibraryDesugaring(libs.androiddesugarlibs) + + // Lifecycle + implementation(libs.bundles.androidx.lifecycle) + + // UI + implementation(libs.google.material) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.webkit) + implementation(libs.modernandroidpreferences) + + // Jetpack Compose + implementation(libs.bundles.compose) + + // Network + val sdkVersion = findProperty("sdk.version")?.toString() + implementation(libs.jellyfin.sdk) { + // Change version if desired + when (sdkVersion) { + "local" -> version { strictly(JellyfinSdk.LOCAL) } + "snapshot" -> version { strictly(JellyfinSdk.SNAPSHOT) } + "unstable-snapshot" -> version { strictly(JellyfinSdk.SNAPSHOT_UNSTABLE) } + } + } + implementation(libs.okhttp) + implementation(libs.coil) + implementation(libs.cronet.embedded) + + // Media + implementation(libs.androidx.media) + implementation(libs.androidx.mediarouter) + implementation(libs.bundles.exoplayer) { + // Exclude Play Services cronet provider library + exclude("com.google.android.gms", "play-services-cronet") + } + implementation(libs.jellyfin.exoplayer.ffmpegextension) + proprietaryImplementation(libs.exoplayer.cast) + proprietaryImplementation(libs.bundles.playservices) + + // Room + implementation(libs.bundles.androidx.room) + ksp(libs.androidx.room.compiler) + + // Monitoring + implementation(libs.timber) + debugImplementation(libs.leakcanary) + + // Testing + testImplementation(libs.junit.api) + testRuntimeOnly(libs.junit.engine) + testImplementation(libs.bundles.kotest) + testImplementation(libs.mockk) + androidTestImplementation(libs.bundles.androidx.test) + + // Formatting rules for detekt + detektPlugins(libs.detekt.formatting) +} + +tasks { + withType { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + } + + withType { + jvmTarget = JavaVersion.VERSION_11.toString() + + reports { + html.required.set(true) + xml.required.set(false) + txt.required.set(true) + sarif.required.set(true) + } + } + + // Testing + withType { + useJUnitPlatform() + testLogging { + outputs.upToDateWhen { false } + showStandardStreams = true + } + } + + register("versionTxt") { + val path = buildDir.resolve("version.txt") + + doLast { + val versionString = "v${android.defaultConfig.versionName}=${android.defaultConfig.versionCode}" + println("Writing [$versionString] to $path") + path.writeText("$versionString\n") + } + } +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..7b1d958 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,34 @@ +# 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 + +# Keep names of all Jellyfin classes +-keepnames class org.jellyfin.mobile.**.* { *; } +-keepnames interface org.jellyfin.mobile.**.* { *; } + +# Keep WebView JS interfaces +-keepclassmembers class org.jellyfin.mobile.bridge.* { + @android.webkit.JavascriptInterface public *; +} + +# Keep Chromecast methods +-keepclassmembers class org.jellyfin.mobile.player.cast.Chromecast { + public *; +} + +# Keep file names/line numbers +-keepattributes SourceFile,LineNumberTable + +# Keep custom exceptions +-keep public class * extends java.lang.Exception + +# Keep AndroidX ComponentFactory +-keep class androidx.core.app.CoreComponentFactory { *; } + +# Assume SDK >= 21 to remove unnecessary compat code +-assumevalues class android.os.Build$VERSION { + int SDK_INT return 21..2147483647; +} diff --git a/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/1.json b/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/1.json new file mode 100644 index 0000000..30bcc6b --- /dev/null +++ b/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/1.json @@ -0,0 +1,94 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "1888633e841bb503cfc73de0c979f8fc", + "entities": [ + { + "tableName": "Server", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `hostname` TEXT NOT NULL, `last_used_timestamp` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hostname", + "columnName": "hostname", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUsedTimestamp", + "columnName": "last_used_timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_Server_hostname", + "unique": true, + "columnNames": [ + "hostname" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Server_hostname` ON `${TABLE_NAME}` (`hostname`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "User", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`server_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `access_token` TEXT, `last_login_timestamp` INTEGER NOT NULL, PRIMARY KEY(`server_id`, `user_id`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "access_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastLoginTimestamp", + "columnName": "last_login_timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "server_id", + "user_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "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, '1888633e841bb503cfc73de0c979f8fc')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/2.json b/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/2.json new file mode 100644 index 0000000..24946cc --- /dev/null +++ b/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/2.json @@ -0,0 +1,123 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "b88354b3000c5abb5c19bfea2813d43a", + "entities": [ + { + "tableName": "Server", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `hostname` TEXT NOT NULL, `last_used_timestamp` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hostname", + "columnName": "hostname", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUsedTimestamp", + "columnName": "last_used_timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_Server_hostname", + "unique": true, + "columnNames": [ + "hostname" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Server_hostname` ON `${TABLE_NAME}` (`hostname`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "User", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `server_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `access_token` TEXT, `last_login_timestamp` INTEGER NOT NULL, FOREIGN KEY(`server_id`) REFERENCES `Server`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "access_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastLoginTimestamp", + "columnName": "last_login_timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_User_server_id_user_id", + "unique": true, + "columnNames": [ + "server_id", + "user_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_User_server_id_user_id` ON `${TABLE_NAME}` (`server_id`, `user_id`)" + } + ], + "foreignKeys": [ + { + "table": "Server", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "server_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "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, 'b88354b3000c5abb5c19bfea2813d43a')" + ] + } +} \ No newline at end of file diff --git a/app/src/debug/res/color-v24/splash_fill.xml b/app/src/debug/res/color-v24/splash_fill.xml new file mode 100644 index 0000000..03955a6 --- /dev/null +++ b/app/src/debug/res/color-v24/splash_fill.xml @@ -0,0 +1,8 @@ + diff --git a/app/src/debug/res/drawable/app_logo.xml b/app/src/debug/res/drawable/app_logo.xml new file mode 100644 index 0000000..f6c30ad --- /dev/null +++ b/app/src/debug/res/drawable/app_logo.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/debug/res/drawable/ic_launcher_foreground.xml b/app/src/debug/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..53c5a6d --- /dev/null +++ b/app/src/debug/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/app/src/debug/res/values/colors.xml b/app/src/debug/res/values/colors.xml new file mode 100644 index 0000000..cdde88f --- /dev/null +++ b/app/src/debug/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #000000 + diff --git a/app/src/debug/res/values/strings_donottranslate.xml b/app/src/debug/res/values/strings_donottranslate.xml new file mode 100644 index 0000000..5f82fbe --- /dev/null +++ b/app/src/debug/res/values/strings_donottranslate.xml @@ -0,0 +1,4 @@ + + + Jellyfin Debug + diff --git a/app/src/libre/AndroidManifest.xml b/app/src/libre/AndroidManifest.xml new file mode 100644 index 0000000..152212c --- /dev/null +++ b/app/src/libre/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/libre/java/org/jellyfin/mobile/player/cast/CastPlayerProvider.kt b/app/src/libre/java/org/jellyfin/mobile/player/cast/CastPlayerProvider.kt new file mode 100644 index 0000000..70d8482 --- /dev/null +++ b/app/src/libre/java/org/jellyfin/mobile/player/cast/CastPlayerProvider.kt @@ -0,0 +1,10 @@ +package org.jellyfin.mobile.player.cast + +import com.google.android.exoplayer2.Player +import org.jellyfin.mobile.player.audio.MediaService + +class CastPlayerProvider(@Suppress("UNUSED_PARAMETER") mediaService: MediaService) : ICastPlayerProvider { + override val isCastSessionAvailable: Boolean = false + + override fun get(): Player? = null +} diff --git a/app/src/libre/java/org/jellyfin/mobile/player/cast/Chromecast.kt b/app/src/libre/java/org/jellyfin/mobile/player/cast/Chromecast.kt new file mode 100644 index 0000000..49163fd --- /dev/null +++ b/app/src/libre/java/org/jellyfin/mobile/player/cast/Chromecast.kt @@ -0,0 +1,11 @@ +package org.jellyfin.mobile.player.cast + +import android.app.Activity +import org.jellyfin.mobile.bridge.JavascriptCallback +import org.json.JSONArray + +class Chromecast : IChromecast { + override fun initializePlugin(activity: Activity) = Unit + override fun execute(action: String, args: JSONArray, cbContext: JavascriptCallback) = false + override fun destroy() = Unit +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..39533e9 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/native/EventEmitter.js b/app/src/main/assets/native/EventEmitter.js new file mode 100644 index 0000000..313e3c9 --- /dev/null +++ b/app/src/main/assets/native/EventEmitter.js @@ -0,0 +1,442 @@ +/*! + * EventEmitter v4.2.11 - git.io/ee + * Unlicense - http://unlicense.org/ + * Oliver Caldwell - http://oli.me.uk/ + * @preserve + */ +(function () { + 'use strict'; + + /** + * Class for managing events. + * Can be extended to provide event functionality in other classes. + * + * @class EventEmitter Manages event registering and emitting. + */ + function EventEmitter () {} + + // Shortcuts to improve speed and size + var proto = EventEmitter.prototype; + + /** + * Finds the index of the listener for the event in its storage array. + * + * @param {Function[]} listeners Array of listeners to search through. + * @param {Function} listener Method to look for. + * @return {Number} Index of the specified listener, -1 if not found + * @api private + */ + function indexOfListener (listeners, listener) { + var i = listeners.length; + while (i--) { + if (listeners[i].listener === listener) { + return i; + } + } + + return -1; + } + + /** + * Alias a method while keeping the context correct, to allow for overwriting of target method. + * + * @param {String} name The name of the target method. + * @return {Function} The aliased method + * @api private + */ + function alias (name) { + return function aliasClosure () { + return this[name].apply(this, arguments); + }; + } + + /** + * Returns the listener array for the specified event. + * Will initialise the event object and listener arrays if required. + * Will return an object if you use a regex search. The object contains keys for each matched event. So /ba[rz]/ might return an object containing bar and baz. But only if you have either defined them with defineEvent or added some listeners to them. + * Each property in the object response is an array of listener functions. + * + * @param {String|RegExp} evt Name of the event to return the listeners from. + * @return {Function[]|Object} All listener functions for the event. + */ + proto.getListeners = function getListeners (evt) { + var events = this._getEvents(); + var response; + var key; + + // Return a concatenated array of all matching events if + // the selector is a regular expression. + if (evt instanceof RegExp) { + response = {}; + for (key in events) { + if (events.hasOwnProperty(key) && evt.test(key)) { + response[key] = events[key]; + } + } + } else { + response = events[evt] || (events[evt] = []); + } + + return response; + }; + + /** + * Takes a list of listener objects and flattens it into a list of listener functions. + * + * @param {Object[]} listeners Raw listener objects. + * @return {Function[]} Just the listener functions. + */ + proto.flattenListeners = function flattenListeners (listeners) { + var flatListeners = []; + var i; + + for (i = 0; i < listeners.length; i += 1) { + flatListeners.push(listeners[i].listener); + } + + return flatListeners; + }; + + /** + * Fetches the requested listeners via getListeners but will always return the results inside an object. This is mainly for internal use but others may find it useful. + * + * @param {String|RegExp} evt Name of the event to return the listeners from. + * @return {Object} All listener functions for an event in an object. + */ + proto.getListenersAsObject = function getListenersAsObject (evt) { + var listeners = this.getListeners(evt); + var response; + + if (listeners instanceof Array) { + response = {}; + response[evt] = listeners; + } + + return response || listeners; + }; + + /** + * Adds a listener function to the specified event. + * The listener will not be added if it is a duplicate. + * If the listener returns true then it will be removed after it is called. + * If you pass a regular expression as the event name then the listener will be added to all events that match it. + * + * @param {String|RegExp} evt Name of the event to attach the listener to. + * @param {Function} listener Method to be called when the event is emitted. If the function returns true then it will be removed after calling. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.addListener = function addListener (evt, listener) { + var listeners = this.getListenersAsObject(evt); + var listenerIsWrapped = typeof listener === 'object'; + var key; + + for (key in listeners) { + if (listeners.hasOwnProperty(key) && indexOfListener(listeners[key], listener) === -1) { + listeners[key].push(listenerIsWrapped ? listener : { + listener: listener, + once: false + }); + } + } + + return this; + }; + + /** + * Alias of addListener + */ + proto.on = alias('addListener'); + + /** + * Semi-alias of addListener. It will add a listener that will be + * automatically removed after its first execution. + * + * @param {String|RegExp} evt Name of the event to attach the listener to. + * @param {Function} listener Method to be called when the event is emitted. If the function returns true then it will be removed after calling. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.addOnceListener = function addOnceListener (evt, listener) { + return this.addListener(evt, { + listener: listener, + once: true + }); + }; + + /** + * Alias of addOnceListener. + */ + proto.once = alias('addOnceListener'); + + /** + * Defines an event name. This is required if you want to use a regex to add a listener to multiple events at once. If you don't do this then how do you expect it to know what event to add to? Should it just add to every possible match for a regex? No. That is scary and bad. + * You need to tell it what event names should be matched by a regex. + * + * @param {String} evt Name of the event to create. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.defineEvent = function defineEvent (evt) { + this.getListeners(evt); + return this; + }; + + /** + * Uses defineEvent to define multiple events. + * + * @param {String[]} evts An array of event names to define. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.defineEvents = function defineEvents (evts) { + for (var i = 0; i < evts.length; i += 1) { + this.defineEvent(evts[i]); + } + return this; + }; + + /** + * Removes a listener function from the specified event. + * When passed a regular expression as the event name, it will remove the listener from all events that match it. + * + * @param {String|RegExp} evt Name of the event to remove the listener from. + * @param {Function} listener Method to remove from the event. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.removeListener = function removeListener (evt, listener) { + var listeners = this.getListenersAsObject(evt); + var index; + var key; + + for (key in listeners) { + if (listeners.hasOwnProperty(key)) { + index = indexOfListener(listeners[key], listener); + + if (index !== -1) { + listeners[key].splice(index, 1); + } + } + } + + return this; + }; + + /** + * Alias of removeListener + */ + proto.off = alias('removeListener'); + + /** + * Adds listeners in bulk using the manipulateListeners method. + * If you pass an object as the second argument you can add to multiple events at once. The object should contain key value pairs of events and listeners or listener arrays. You can also pass it an event name and an array of listeners to be added. + * You can also pass it a regular expression to add the array of listeners to all events that match it. + * Yeah, this function does quite a bit. That's probably a bad thing. + * + * @param {String|Object|RegExp} evt An event name if you will pass an array of listeners next. An object if you wish to add to multiple events at once. + * @param {Function[]} [listeners] An optional array of listener functions to add. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.addListeners = function addListeners (evt, listeners) { + // Pass through to manipulateListeners + return this.manipulateListeners(false, evt, listeners); + }; + + /** + * Removes listeners in bulk using the manipulateListeners method. + * If you pass an object as the second argument you can remove from multiple events at once. The object should contain key value pairs of events and listeners or listener arrays. + * You can also pass it an event name and an array of listeners to be removed. + * You can also pass it a regular expression to remove the listeners from all events that match it. + * + * @param {String|Object|RegExp} evt An event name if you will pass an array of listeners next. An object if you wish to remove from multiple events at once. + * @param {Function[]} [listeners] An optional array of listener functions to remove. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.removeListeners = function removeListeners (evt, listeners) { + // Pass through to manipulateListeners + return this.manipulateListeners(true, evt, listeners); + }; + + /** + * Edits listeners in bulk. The addListeners and removeListeners methods both use this to do their job. You should really use those instead, this is a little lower level. + * The first argument will determine if the listeners are removed (true) or added (false). + * If you pass an object as the second argument you can add/remove from multiple events at once. The object should contain key value pairs of events and listeners or listener arrays. + * You can also pass it an event name and an array of listeners to be added/removed. + * You can also pass it a regular expression to manipulate the listeners of all events that match it. + * + * @param {Boolean} remove True if you want to remove listeners, false if you want to add. + * @param {String|Object|RegExp} evt An event name if you will pass an array of listeners next. An object if you wish to add/remove from multiple events at once. + * @param {Function[]} [listeners] An optional array of listener functions to add/remove. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.manipulateListeners = function manipulateListeners (remove, evt, listeners) { + var i; + var value; + var single = remove ? this.removeListener : this.addListener; + var multiple = remove ? this.removeListeners : this.addListeners; + + // If evt is an object then pass each of its properties to this method + if (typeof evt === 'object' && !(evt instanceof RegExp)) { + for (i in evt) { + if (evt.hasOwnProperty(i) && (value = evt[i])) { + // Pass the single listener straight through to the singular method + if (typeof value === 'function') { + single.call(this, i, value); + } else { + // Otherwise pass back to the multiple function + multiple.call(this, i, value); + } + } + } + } else { + // So evt must be a string + // And listeners must be an array of listeners + // Loop over it and pass each one to the multiple method + i = listeners.length; + while (i--) { + single.call(this, evt, listeners[i]); + } + } + + return this; + }; + + /** + * Removes all listeners from a specified event. + * If you do not specify an event then all listeners will be removed. + * That means every event will be emptied. + * You can also pass a regex to remove all events that match it. + * + * @param {String|RegExp} [evt] Optional name of the event to remove all listeners for. Will remove from every event if not passed. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.removeEvent = function removeEvent (evt) { + var type = typeof evt; + var events = this._getEvents(); + var key; + + // Remove different things depending on the state of evt + if (type === 'string') { + // Remove all listeners for the specified event + delete events[evt]; + } else if (evt instanceof RegExp) { + // Remove all events matching the regex. + for (key in events) { + if (events.hasOwnProperty(key) && evt.test(key)) { + delete events[key]; + } + } + } else { + // Remove all listeners in all events + delete this._events; + } + + return this; + }; + + /** + * Alias of removeEvent. + * + * Added to mirror the node API. + */ + proto.removeAllListeners = alias('removeEvent'); + + /** + * Emits an event of your choice. + * When emitted, every listener attached to that event will be executed. + * If you pass the optional argument array then those arguments will be passed to every listener upon execution. + * Because it uses `apply`, your array of arguments will be passed as if you wrote them out separately. + * So they will not arrive within the array on the other side, they will be separate. + * You can also pass a regular expression to emit to all events that match it. + * + * @param {String|RegExp} evt Name of the event to emit and execute listeners for. + * @param {Array} [args] Optional array of arguments to be passed to each listener. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.emitEvent = function emitEvent (evt, args) { + var listeners = this.getListenersAsObject(evt); + var listener; + var i; + var key; + var response; + + for (key in listeners) { + if (listeners.hasOwnProperty(key)) { + i = listeners[key].length; + + while (i--) { + // If the listener returns true then it shall be removed from the event + // The function is executed either with a basic call or an apply if there is an args array + listener = listeners[key][i]; + + if (listener.once === true) { + this.removeListener(evt, listener.listener); + } + + response = listener.listener.apply(this, args || []); + + if (response === this._getOnceReturnValue()) { + this.removeListener(evt, listener.listener); + } + } + } + } + + return this; + }; + + /** + * Alias of emitEvent + */ + proto.trigger = alias('emitEvent'); + + /** + * Subtly different from emitEvent in that it will pass its arguments on to the listeners, as opposed to taking a single array of arguments to pass on. + * As with emitEvent, you can pass a regex in place of the event name to emit to all events that match it. + * + * @param {String|RegExp} evt Name of the event to emit and execute listeners for. + * @param {...*} Optional additional arguments to be passed to each listener. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.emit = function emit (evt) { + var args = Array.prototype.slice.call(arguments, 1); + return this.emitEvent(evt, args); + }; + + /** + * Sets the current value to check against when executing listeners. If a + * listeners return value matches the one set here then it will be removed + * after execution. This value defaults to true. + * + * @param {*} value The new value to check for when executing listeners. + * @return {Object} Current instance of EventEmitter for chaining. + */ + proto.setOnceReturnValue = function setOnceReturnValue (value) { + this._onceReturnValue = value; + return this; + }; + + /** + * Fetches the current value to check against when executing listeners. If + * the listeners return value matches this one then it should be removed + * automatically. It will return true by default. + * + * @return {*|Boolean} The current value to check for or the default, true. + * @api private + */ + proto._getOnceReturnValue = function _getOnceReturnValue () { + if (this.hasOwnProperty('_onceReturnValue')) { + return this._onceReturnValue; + } else { + return true; + } + }; + + /** + * Fetches the events object and creates one if required. + * + * @return {Object} The events storage object. + * @api private + */ + proto._getEvents = function _getEvents () { + return this._events || (this._events = {}); + }; + + window.CastPluginEventEmitter = EventEmitter; +}()); diff --git a/app/src/main/assets/native/ExoPlayerPlugin.js b/app/src/main/assets/native/ExoPlayerPlugin.js new file mode 100644 index 0000000..a5cc27d --- /dev/null +++ b/app/src/main/assets/native/ExoPlayerPlugin.js @@ -0,0 +1,179 @@ +export class ExoPlayerPlugin { + constructor({ events, playbackManager, loading }) { + window['ExoPlayer'] = this; + + this.events = events; + this.playbackManager = playbackManager; + this.loading = loading; + + this.name = 'ExoPlayer'; + this.type = 'mediaplayer'; + this.id = 'exoplayer'; + + // Prioritize first + this.priority = -1; + this.isLocalPlayer = true; + + // Current playback position in milliseconds + this._currentTime = 0; + this._paused = true; + + this._nativePlayer = window['NativePlayer']; + } + + async play(options) { + // Sanitize input + options.ids = options.items.map(item => item.Id); + delete options.items; + + this._paused = false; + this._nativePlayer.loadPlayer(JSON.stringify(options)); + this.loading.hide(); + } + + shuffle(item) {} + + instantMix(item) {} + + queue(options) {} + + queueNext(options) {} + + canPlayMediaType(mediaType) { + return mediaType === 'Video'; + } + + canQueueMediaType(mediaType) { + return this.canPlayMediaType(mediaType); + } + + canPlayItem(item, playOptions) { + return this._nativePlayer.isEnabled() && + playOptions.fullscreen && + !this.playbackManager.syncPlayEnabled; + } + + async stop(destroyPlayer) { + this._nativePlayer.stopPlayer(); + + if (destroyPlayer) { + this.destroy(); + } + } + + nextTrack() {} + + previousTrack() {} + + seek(ticks) { + this._nativePlayer.seek(ticks); + } + + currentTime(ms) { + if (ms !== undefined) { + this._nativePlayer.seekMs(ms); + } + return this._currentTime; + } + + duration(val) { + return null; + } + + /** + * Get or set volume percentage as as string + */ + volume(volume) { + if (volume !== undefined) { + this.setVolume(volume); + } + return null; + } + + getVolume() {} + + setVolume(vol) { + let volume = parseInt(vol); + this._nativePlayer.setVolume(volume); + } + + volumeUp() {} + + volumeDown() {} + + isMuted() { + return false; + } + + setMute(mute) { + // Assume 30% as default when unmuting + this._nativePlayer.setVolume(mute ? 0 : 30); + } + + toggleMute() {} + + paused() { + return this._paused; + } + + pause() { + this._paused = true; + this._nativePlayer.pausePlayer(); + } + + unpause() { + this._paused = false; + this._nativePlayer.resumePlayer(); + } + + playPause() { + if (this._paused) { + this.unpause(); + } else { + this.pause(); + } + } + + canSetAudioStreamIndex() { + return false; + } + + setAudioStreamIndex(index) {} + + setSubtitleStreamIndex(index) {} + + async changeAudioStream(index) {} + + async changeSubtitleStream(index) {} + + getPlaylist() { + return Promise.resolve([]); + } + + getCurrentPlaylistItemId() {} + + setCurrentPlaylistItem() { + return Promise.resolve(); + } + + removeFromPlaylist() { + return Promise.resolve(); + } + + destroy() { + this._nativePlayer.destroyPlayer(); + } + + async getDeviceProfile() { + return { + Name: 'ExoPlayer Stub', + MaxStreamingBitrate: 100000000, + MaxStaticBitrate: 100000000, + MusicStreamingTranscodingBitrate: 320000, + DirectPlayProfiles: [{Type: 'Video'}, {Type: 'Audio'}], + CodecProfiles: [], + SubtitleProfiles: [], + TranscodingProfiles: [] + }; + } +} diff --git a/app/src/main/assets/native/ExternalPlayerPlugin.js b/app/src/main/assets/native/ExternalPlayerPlugin.js new file mode 100644 index 0000000..b8a6447 --- /dev/null +++ b/app/src/main/assets/native/ExternalPlayerPlugin.js @@ -0,0 +1,152 @@ +export class ExternalPlayerPlugin { + constructor({ events, playbackManager }) { + window['ExtPlayer'] = this; + + this.events = events; + this.playbackManager = playbackManager; + + this.name = 'External Player'; + this.type = 'mediaplayer'; + this.id = 'externalplayer'; + this.subtitleStreamIndex = -1; + this.audioStreamIndex = -1; + this.cachedDeviceProfile = null; + + // Prioritize first + this.priority = -2; + this.supportsProgress = false; + this.isLocalPlayer = true; + + // Disable orientation lock + this.isExternalPlayer = true; + // _currentTime is in milliseconds + this._currentTime = 0; + this._paused = true; + this._volume = 100; + this._currentSrc = null; + this._isIntro = false; + + this._externalPlayer = window['ExternalPlayer']; + } + + canPlayMediaType(mediaType) { + return mediaType === 'Video'; + } + + canPlayItem(item, playOptions) { + return this._externalPlayer.isEnabled() && + playOptions.fullscreen && + !this.playbackManager.syncPlayEnabled; + } + + currentSrc() { + return this._currentSrc; + } + + async play(options) { + this._currentTime = options.playerStartPositionTicks / 10000 || 0; + this._paused = false; + this._currentSrc = options.url; + this._isIntro = options.item && options.item.ProviderIds && options.item.ProviderIds.hasOwnProperty("prerolls.video"); + const playOptions = options.item.playOptions; + playOptions.ids = options.item ? [options.item.Id] : []; + this._externalPlayer.initPlayer(JSON.stringify(playOptions)); + } + + setSubtitleStreamIndex(index) { } + + canSetAudioStreamIndex() { + return false; + } + + setAudioStreamIndex(index) { + } + + duration(val) { + return null; + } + + destroy() { } + + pause() { } + + unpause() { } + + paused() { + return this._paused; + } + + async stop(destroyPlayer) { + if (destroyPlayer) { + this.destroy(); + } + } + + volume(val) { + return this._volume; + } + + setMute(mute) { + } + + isMuted() { + return this._volume == 0; + } + + async notifyEnded() { + let stopInfo = { + src: this._currentSrc + }; + + this.playbackManager._playNextAfterEnded = this._isIntro; + this.events.trigger(this, 'stopped', [stopInfo]); + this._currentSrc = this._currentTime = null; + } + + async notifyTimeUpdate(currentTime) { + // Use duration (as if playback completed) if no time is provided + currentTime = currentTime || this.playbackManager.duration(this) / 10000; + this._timeUpdated = this._currentTime != currentTime; + this._currentTime = currentTime; + this.events.trigger(this, 'timeupdate'); + } + + notifyCanceled() { + // required to not mark an item as seen / completed without time changes + let currentTime = this._currentTime || 0; + this.notifyTimeUpdate(currentTime - 1); + if (currentTime > 0) { + this.notifyTimeUpdate(currentTime); + } + this.notifyEnded(); + } + + /** + * Returns the currently known player time in milliseconds + */ + currentTime() { + return this._currentTime || 0; + } + + async changeSubtitleStream(index) { + var innerIndex = Number(index); + this.subtitleStreamIndex = innerIndex; + } + + async changeAudioStream(index) { + var innerIndex = Number(index); + this.audioStreamIndex = innerIndex; + } + + async getDeviceProfile() { + return { + Name: 'Android External Player Stub', + MaxStreamingBitrate: 1_000_000_000, + MaxStaticBitrate: 1_000_000_000, + DirectPlayProfiles: [{Type: 'Video'}, {Type: 'Audio'}], + CodecProfiles: [], + SubtitleProfiles: [{Method: 'Embed'}, {Method: 'External'}, {Method: 'Drop'}], + TranscodingProfiles: [] + }; + } +} diff --git a/app/src/main/assets/native/NavigationPlugin.js b/app/src/main/assets/native/NavigationPlugin.js new file mode 100644 index 0000000..cb1826b --- /dev/null +++ b/app/src/main/assets/native/NavigationPlugin.js @@ -0,0 +1,16 @@ +export class NavigationPlugin { + constructor({ playbackManager }) { + window['NavigationHelper'] = this; + + this.playbackManager = playbackManager; + } + + goBack() { + var appRouter = window['Emby']['Page']; + if (appRouter.canGoBack()) { + appRouter.back(); + } else { + window['NativeInterface'].exitApp(); + } + } +} diff --git a/app/src/main/assets/native/chrome.cast.js b/app/src/main/assets/native/chrome.cast.js new file mode 100644 index 0000000..b9c8d8f --- /dev/null +++ b/app/src/main/assets/native/chrome.cast.js @@ -0,0 +1,1443 @@ +/** + * Portions of this page are modifications based on work created and shared by + * Google and used according to terms described in the Creative Commons 3.0 + * Attribution License. + */ +var EventEmitter = window.CastPluginEventEmitter; + +var chrome = {}; + +chrome.cast = { + + /** + * The API version. + * @type {Array} + */ + VERSION: [1, 1], + + /** + * Describes availability of a Cast receiver. + * AVAILABLE: At least one receiver is available that is compatible with the session request. + * UNAVAILABLE: No receivers are available. + * @type {Object} + */ + ReceiverAvailability: { AVAILABLE: 'available', UNAVAILABLE: 'unavailable' }, + + /** + * TODO: Update when the official API docs are finished + * https://developers.google.com/cast/docs/reference/chrome/chrome.cast.ReceiverType + * CAST: + * DIAL: + * CUSTOM: + * @type {Object} + */ + ReceiverType: { CAST: 'cast', DIAL: 'dial', CUSTOM: 'custom' }, + + /** + * Describes a sender application platform. + * CHROME: + * IOS: + * ANDROID: + * @type {Object} + */ + SenderPlatform: { CHROME: 'chrome', IOS: 'ios', ANDROID: 'android' }, + + /** + * Auto-join policy determines when the SDK will automatically connect a sender application to an existing session after API initialization. + * ORIGIN_SCOPED: Automatically connects when the session was started with the same appId and the same page origin (regardless of tab). + * PAGE_SCOPED: No automatic connection. + * TAB_AND_ORIGIN_SCOPED: Automatically connects when the session was started with the same appId, in the same tab and page origin. + * @type {Object} + */ + AutoJoinPolicy: { TAB_AND_ORIGIN_SCOPED: 'tab_and_origin_scoped', ORIGIN_SCOPED: 'origin_scoped', PAGE_SCOPED: 'page_scoped' }, + + /** + * Capabilities that are supported by the receiver device. + * AUDIO_IN: The receiver supports audio input (microphone). + * AUDIO_OUT: The receiver supports audio output. + * VIDEO_IN: The receiver supports video input (camera). + * VIDEO_OUT: The receiver supports video output. + * @type {Object} + */ + Capability: { VIDEO_OUT: 'video_out', AUDIO_OUT: 'audio_out', VIDEO_IN: 'video_in', AUDIO_IN: 'audio_in' }, + + /** + * Default action policy determines when the SDK will automatically create a session after initializing the API. This also controls the default action for the tab in the extension popup. + * CAST_THIS_TAB: No automatic launch is done after initializing the API, even if the tab is being cast. + * CREATE_SESSION: If the tab containing the app is being casted when the API initializes, the SDK stops tab casting and automatically launches the app. + * @type {Object} + */ + DefaultActionPolicy: { CREATE_SESSION: 'create_session', CAST_THIS_TAB: 'cast_this_tab' }, + + /** + * Errors that may be returned by the SDK. + * API_NOT_INITIALIZED: The API is not initialized. + * CANCEL: The operation was canceled by the user. + * CHANNEL_ERROR: A channel to the receiver is not available. + * EXTENSION_MISSING: The Cast extension is not available. + * EXTENSION_NOT_COMPATIBLE: The API script is not compatible with the installed Cast extension. + * INVALID_PARAMETER: The parameters to the operation were not valid. + * LOAD_MEDIA_FAILED: Load media failed. + * RECEIVER_UNAVAILABLE: No receiver was compatible with the session request. + * SESSION_ERROR: A session could not be created, or a session was invalid. + * TIMEOUT: The operation timed out. + * @type {Object} + */ + ErrorCode: { + API_NOT_INITIALIZED: 'api_not_initialized', + CANCEL: 'cancel', + CHANNEL_ERROR: 'channel_error', + EXTENSION_MISSING: 'extension_missing', + EXTENSION_NOT_COMPATIBLE: 'extension_not_compatible', + INVALID_PARAMETER: 'invalid_parameter', + LOAD_MEDIA_FAILED: 'load_media_failed', + RECEIVER_UNAVAILABLE: 'receiver_unavailable', + SESSION_ERROR: 'session_error', + TIMEOUT: 'timeout', + UNKNOWN: 'unknown', + NOT_IMPLEMENTED: 'not_implemented' + }, + + SessionStatus: { CONNECTED: 'connected', DISCONNECTED: 'disconnected', STOPPED: 'stopped' }, + + /** + * TODO: Update when the official API docs are finished + * https://developers.google.com/cast/docs/reference/chrome/chrome.cast.timeout + * @type {Object} + */ + timeout: { + requestSession: 10000, + sendCustomMessage: 3000, + setReceiverVolume: 3000, + stopSession: 3000 + }, + + /** + * Flag for clients to check whether the API is loaded. + * @type {Boolean} + */ + isAvailable: false, + + /** + * [ApiConfig description] + * @param {chrome.cast.SessionRequest} sessionRequest Describes the session to launch or the session to connect. + * @param {function} sessionListener Listener invoked when a session is created or connected by the SDK. + * @param {function} receiverListener Function invoked when the availability of a Cast receiver that supports the application in sessionRequest is known or changes. + * @param {chrome.cast.AutoJoinPolicy} autoJoinPolicy Determines whether the SDK will automatically connect to a running session after initialization. + * @param {chrome.cast.DefaultActionPolicy} defaultActionPolicy Requests whether the application should be launched on API initialization when the tab is already being cast. + */ + ApiConfig: function (sessionRequest, sessionListener, receiverListener, autoJoinPolicy, defaultActionPolicy) { + this.sessionRequest = sessionRequest; + this.sessionListener = sessionListener; + this.receiverListener = receiverListener; + this.autoJoinPolicy = autoJoinPolicy || chrome.cast.AutoJoinPolicy.TAB_AND_ORIGIN_SCOPED; + this.defaultActionPolicy = defaultActionPolicy || chrome.cast.DefaultActionPolicy.CREATE_SESSION; + }, + + /** + * TODO: Update when the official API docs are finished + * https://developers.google.com/cast/docs/reference/chrome/chrome.cast.DialRequest + * @param {[type]} appName [description] + * @param {[type]} launchParameter [description] + */ + DialRequest: function (appName, launchParameter) { + this.appName = appName; + this.launchParameter = launchParameter; + }, + + /** + * A request to start or connect to a session. + * @param {string} appId The receiver application id. + * @param {chrome.cast.Capability[]} capabilities Capabilities required of the receiver device. + * @property {chrome.cast.DialRequest} dialRequest If given, the SDK will also discover DIAL devices that support the DIAL application given in the dialRequest. + */ + SessionRequest: function (appId, capabilities) { + this.appId = appId; + this.capabilities = capabilities || [chrome.cast.Capability.VIDEO_OUT, chrome.cast.Capability.AUDIO_OUT]; + this.dialRequest = null; + }, + + /** + * Describes an error returned by the API. Normally, these objects should not be created by the client. + * @param {chrome.cast.ErrorCode} code The error code. + * @param {string} description Human readable description of the error. + * @param {Object} details Details specific to the error. + */ + Error: function (code, description, details) { + this.code = code; + this.description = description || null; + this.details = details || null; + }, + + /** + * An image that describes a receiver application or media item. This could be an application icon, cover art, or a thumbnail. + * @param {string} url The URL to the image. + * @property {number} height The height of the image + * @property {number} width The width of the image + */ + Image: function (url) { + this.url = url; + this.width = this.height = null; + }, + + /** + * Describes a sender application. Normally, these objects should not be created by the client. + * @param {chrome.cast.SenderPlatform} platform The supported platform. + * @property {string} packageId The identifier or URL for the application in the respective platform's app store. + * @property {string} url URL or intent to launch the application. + */ + SenderApplication: function (platform) { + this.platform = platform; + this.packageId = this.url = null; + }, + + // media package + media: { + /** + * The default receiver app. + */ + DEFAULT_MEDIA_RECEIVER_APP_ID: 'CC1AD845', + + /** + * Possible states of the media player. + * BUFFERING: Player is in PLAY mode but not actively playing content. currentTime will not change. + * IDLE: No media is loaded into the player. + * PAUSED: The media is not playing. + * PLAYING: The media is playing. + * @type {Object} + */ + PlayerState: { IDLE: 'IDLE', PLAYING: 'PLAYING', PAUSED: 'PAUSED', BUFFERING: 'BUFFERING' }, + + /** + * Possible reason why a media is idle. + * CANCELLED: A sender requested to stop playback using the STOP command. + * INTERRUPTED: A sender requested playing a different media using the LOAD command. + * FINISHED: The media playback completed. + * ERROR: The media was interrupted due to an error, this could be for example if the player could not download media due to networking errors. + */ + IdleReason: { CANCELLED: 'CANCELLED', INTERRUPTED: 'INTERRUPTED', FINISHED: 'FINISHED', ERROR: 'ERROR' }, + + /** + * Possible states of queue repeat mode. + * OFF: Items are played in order, and when the queue is completed (the last item has ended) the media session is terminated. + * ALL: The items in the queue will be played indefinitely. When the last item has ended, the first item will be played again. + * SINGLE: The current item will be repeated indefinitely. + * ALL_AND_SHUFFLE: The items in the queue will be played indefinitely. When the last item has ended, the list of items will be randomly shuffled by the receiver, and the queue will continue to play starting from the first item of the shuffled items. + */ + RepeatMode: { OFF: 'REPEAT_OFF', ALL: 'REPEAT_ALL', SINGLE: 'REPEAT_SINGLE', ALL_AND_SHUFFLE: 'REPEAT_ALL_AND_SHUFFLE' }, + + /** + * States of the media player after resuming. + * PLAYBACK_PAUSE: Force media to pause. + * PLAYBACK_START: Force media to start. + * @type {Object} + */ + ResumeState: { PLAYBACK_START: 'PLAYBACK_START', PLAYBACK_PAUSE: 'PLAYBACK_PAUSE' }, + + /** + * Possible media commands supported by the receiver application. + * @type {Object} + */ + MediaCommand: { PAUSE: 'pause', SEEK: 'seek', STREAM_VOLUME: 'stream_volume', STREAM_MUTE: 'stream_mute' }, + + /** + * Possible types of media metadata. + * GENERIC: Generic template suitable for most media types. Used by chrome.cast.media.GenericMediaMetadata. + * MOVIE: A full length movie. Used by chrome.cast.media.MovieMediaMetadata. + * MUSIC_TRACK: A music track. Used by chrome.cast.media.MusicTrackMediaMetadata. + * PHOTO: Photo. Used by chrome.cast.media.PhotoMediaMetadata. + * TV_SHOW: An episode of a TV series. Used by chrome.cast.media.TvShowMediaMetadata. + * @type {Object} + */ + MetadataType: { GENERIC: 0, MOVIE: 1, TV_SHOW: 2, MUSIC_TRACK: 3, PHOTO: 4, AUDIOBOOK_CHAPTER: 5 }, + + /** + * Possible media stream types. + * BUFFERED: Stored media streamed from an existing data store. + * LIVE: Live media generated on the fly. + * OTHER: None of the above. + * @type {Object} + */ + StreamType: { BUFFERED: 'buffered', LIVE: 'live', OTHER: 'other' }, + + /** + * TODO: Update when the official API docs are finished + * https://developers.google.com/cast/docs/reference/chrome/chrome.cast.media.timeout + * @type {Object} + */ + timeout: { + load: 0, + ob: 0, + pause: 0, + play: 0, + seek: 0, + setVolume: 0, + stop: 0 + }, + + /** + * A request to load new media into the player. + * @param {chrome.cast.media.MediaInfo} media Media description. + * @property {boolean} autoplay Whether the media will automatically play. + * @property {number} currentTime Seconds from the beginning of the media to start playback. + * @property {Object} customData Custom data for the receiver application. + */ + LoadRequest: function LoadRequest (media) { + this.type = 'LOAD'; + this.sessionId = this.requestId = this.customData = this.currentTime = null; + this.media = media; + this.autoplay = !0; + }, + + /** + * A request to play the currently paused media. + * @property {Object} customData Custom data for the receiver application. + */ + PlayRequest: function PlayRequest () { + this.customData = null; + }, + + /** + * A request to seek the current media. + * @property {number} currentTime The new current time for the media, in seconds after the start of the media. + * @property {chrome.cast.media.ResumeState} resumeState The desired media player state after the seek is complete. + * @property {Object} customData Custom data for the receiver application. + */ + SeekRequest: function SeekRequest () { + this.customData = this.resumeState = this.currentTime = null; + }, + + /** + * A request to set the stream volume of the playing media. + * @param {chrome.cast.Volume} volume The new volume of the stream. + * @property {Object} customData Custom data for the receiver application. + */ + VolumeRequest: function VolumeRequest (volume) { + this.volume = volume; + this.customData = null; + }, + + /** + * A request to stop the media player. + * @property {Object} customData Custom data for the receiver application. + */ + StopRequest: function StopRequest () { + this.customData = null; + }, + + /** + * A request to pause the currently playing media. + * @property {Object} customData Custom data for the receiver application. + */ + PauseRequest: function PauseRequest () { + this.customData = null; + }, + + /** + * Represents an item in a media queue. + * @param {chrome.cast.media.MediaInfo} mediaInfo - Value must not be null. + */ + QueueItem: function (item) { + this.itemId = null; + this.media = item; + this.autoplay = !0; + this.startTime = 0; + this.playbackDuration = null; + this.preloadTime = 0; + this.customData = this.activeTrackIds = null; + }, + + /** + * A request to load and optionally start playback of a new ordered + * list of media items. + * @param {chrome.cast.media.QueueItem} items - The list of media items + * to load. Must not be null or empty. Value must not be null. + */ + QueueLoadRequest: function (items) { + this.type = 'QUEUE_LOAD'; + this.sessionId = this.requestId = null; + this.items = items; + this.startIndex = 0; + this.repeatMode = chrome.cast.media.RepeatMode.OFF; + this.customData = null; + }, + + /** + * A generic media description. + * @property {chrome.cast.Image[]} images Content images. + * @property {string} releaseDate ISO 8601 date and/or time when the content was released, e.g. + * @property {number} releaseYear Integer year when the content was released. + * @property {string} subtitle Content subtitle. + * @property {string} title Content title. + * @property {chrome.cast.media.MetadataType} type The type of metadata. + */ + GenericMediaMetadata: function GenericMediaMetadata () { + this.metadataType = this.type = chrome.cast.media.MetadataType.GENERIC; + this.releaseDate = this.releaseYear = this.images = this.subtitle = this.title = undefined; + }, + + /** + * A movie media description. + * @property {chrome.cast.Image[]} images Content images. + * @property {string} releaseDate ISO 8601 date and/or time when the content was released, e.g. + * @property {number} releaseYear Integer year when the content was released. + * @property {string} studio Movie studio + * @property {string} subtitle Content subtitle. + * @property {string} title Content title. + * @property {chrome.cast.media.MetadataType} type The type of metadata. + */ + MovieMediaMetadata: function MovieMediaMetadata () { + this.metadataType = this.type = chrome.cast.media.MetadataType.MOVIE; + this.releaseDate = this.releaseYear = this.images = this.subtitle = this.studio = this.title = undefined; + }, + + /** + * A music track media description. + * @property {string} albumArtist Album artist name. + * @property {string} albumName Album name. + * @property {string} artist Track artist name. + * @property {string} artistName Track artist name. + * @property {string} composer Track composer name. + * @property {number} discNumber Disc number. + * @property {chrome.cast.Image[]} images Content images. + * @property {string} releaseDate ISO 8601 date when the track was released, e.g. + * @property {number} releaseYear Integer year when the album was released. + * @property {string} songName Track name. + * @property {string} title Track title. + * @property {number} trackNumber Track number in album. + * @property {chrome.cast.media.MetadataType} type The type of metadata. + */ + MusicTrackMediaMetadata: function MusicTrackMediaMetadata () { + this.metadataType = this.type = chrome.cast.media.MetadataType.MUSIC_TRACK; + this.releaseDate = this.releaseYear = this.images = this.discNumber = this.trackNumber = this.artistName = this.songName = this.composer = this.artist = this.albumArtist = this.title = this.albumName = undefined; + }, + + /** + * A photo media description. + * @property {string} artist Name of the photographer. + * @property {string} creationDateTime ISO 8601 date and time the photo was taken, e.g. + * @property {number} height Photo height, in pixels. + * @property {chrome.cast.Image[]} images Images associated with the content. + * @property {number} latitude Latitude. + * @property {string} location Location where the photo was taken. + * @property {number} longitude Longitude. + * @property {string} title Photo title. + * @property {chrome.cast.media.MetadataType} type The type of metadata. + * @property {number} width Photo width, in pixels. + */ + PhotoMediaMetadata: function PhotoMediaMetadata () { + this.metadataType = this.type = chrome.cast.media.MetadataType.PHOTO; + this.creationDateTime = this.height = this.width = this.longitude = this.latitude = this.images = this.location = this.artist = this.title = undefined; + }, + + /** + * [TvShowMediaMetadata description] + * @property {number} episode TV episode number. + * @property {number} episodeNumber TV episode number. + * @property {string} episodeTitle TV episode title. + * @property {chrome.cast.Image[]} images Content images. + * @property {string} originalAirdate ISO 8601 date when the episode originally aired, e.g. + * @property {number} releaseYear Integer year when the content was released. + * @property {number} season TV episode season. + * @property {number} seasonNumber TV episode season. + * @property {string} seriesTitle TV series title. + * @property {string} title TV episode title. + * @property {chrome.cast.media.MetadataType} type The type of metadata. + */ + TvShowMediaMetadata: function TvShowMediaMetadata () { + this.metadataType = this.type = chrome.cast.media.MetadataType.TV_SHOW; + this.originalAirdate = this.releaseYear = this.images = this.episode = this.episodeNumber = this.season = this.seasonNumber = this.episodeTitle = this.title = this.seriesTitle = undefined; + }, + + /** + * Possible media track types. + */ + TrackType: {TEXT: 'TEXT', AUDIO: 'AUDIO', VIDEO: 'VIDEO'}, + + /** + * Possible text track types. + */ + TextTrackType: {SUBTITLES: 'SUBTITLES', CAPTIONS: 'CAPTIONS', DESCRIPTIONS: 'DESCRIPTIONS', CHAPTERS: 'CHAPTERS', METADATA: 'METADATA'}, + + /** + * Possible text track edge types. + */ + TextTrackEdgeType: {NONE: 'NONE', OUTLINE: 'OUTLINE', DROP_SHADOW: 'DROP_SHADOW', RAISED: 'RAISED', DEPRESSED: 'DEPRESSED'}, + + /** + * Possible text track font generic family. + */ + TextTrackFontGenericFamily: { + CURSIVE: 'CURSIVE', + MONOSPACED_SANS_SERIF: 'MONOSPACED_SANS_SERIF', + MONOSPACED_SERIF: 'MONOSPACED_SERIF', + SANS_SERIF: 'SANS_SERIF', + SERIF: 'SERIF', + SMALL_CAPITALS: 'SMALL_CAPITALS' + }, + + /** + * Possible text track font style. + */ + TextTrackFontStyle: {NORMAL: 'NORMAL', BOLD: 'BOLD', BOLD_ITALIC: 'BOLD_ITALIC', ITALIC: 'ITALIC'}, + + /** + * Possible text track window types. + */ + TextTrackWindowType: {NONE: 'NONE', NORMAL: 'NORMAL', ROUNDED_CORNERS: 'ROUNDED_CORNERS'}, + + /** + * Describes style information for a text track. + * + * Colors are represented as strings "#RRGGBBAA" where XX are the two hexadecimal symbols that represent + * the 0-255 value for the specific channel/color. It follows CSS 8-digit hex color notation (See + * http://dev.w3.org/csswg/css-color/#hex-notation). + */ + TextTrackStyle: function TextTrackStyle () { + this.backgroundColor = this.customData = this.edgeColor = this.edgeType = + this.fontFamily = this.fontGenericFamily = this.fontScale = this.fontStyle = + this.foregroundColor = this.windowColor = this.windowRoundedCornerRadius = + this.windowType = null; + }, + + /** + * A request to modify the text tracks style or change the tracks status. If a trackId does not match + * the existing trackIds the whole request will fail and no status will change. It is acceptable to + * change the text track style even if no text track is currently active. + * @param {number[]} opt_activeTrackIds Optional. + * @param {chrome.cast.media.TextTrackStyle} opt_textTrackSytle Optional. + **/ + EditTracksInfoRequest: function EditTracksInfoRequest (opt_activeTrackIds, opt_textTrackSytle) { + this.activeTrackIds = opt_activeTrackIds; + this.textTrackSytle = opt_textTrackSytle; + this.requestId = null; + } + } +}; + +var _initialized = false; +var _sessionListener; +var _receiverListener; + +var _session; + +/** + * Initializes the API. Note that either successCallback and errorCallback will be invoked once the API has finished initialization. + * The sessionListener and receiverListener may be invoked at any time afterwards, and possibly more than once. + * @param {chrome.cast.ApiConfig} apiConfig The object with parameters to initialize the API. Must not be null. + * @param {function} successCallback + * @param {function} errorCallback + */ +chrome.cast.initialize = function (apiConfig, successCallback, errorCallback) { + execute('initialize', apiConfig.sessionRequest.appId, apiConfig.autoJoinPolicy, apiConfig.defaultActionPolicy, function (err) { + if (!err) { + // Don't set the listeners config until success + _initialized = true; + _sessionListener = apiConfig.sessionListener; + _receiverListener = apiConfig.receiverListener; + successCallback(); + _receiverListener && _receiverListener(chrome.cast.ReceiverAvailability.UNAVAILABLE); + } else { + handleError(err, errorCallback); + } + }); +}; + +/** + * Requests that a receiver application session be created or joined. + * By default, the SessionRequest passed to the API at initialization time is used; + * this may be overridden by passing a different session request in opt_sessionRequest. + * @param {function} successCallback + * @param {function} errorCallback The possible errors are TIMEOUT, INVALID_PARAMETER, API_NOT_INITIALIZED, CANCEL, CHANNEL_ERROR, SESSION_ERROR, RECEIVER_UNAVAILABLE, and EXTENSION_MISSING. Note that the timeout timer starts after users select a receiver. Selecting a receiver requires user's action, which has no timeout. + * @param {chrome.cast.SessionRequest} opt_sessionRequest + */ +chrome.cast.requestSession = function (successCallback, errorCallback, opt_sessionRequest) { + execute('requestSession', function (err, obj) { + if (!err) { + successCallback(createNewSession(obj)); + } else { + handleError(err, errorCallback); + } + }); +}; + +/** + * Sets custom receiver list + * @param {chrome.cast.Receiver[]} receivers The new list. Must not be null. + * @param {function} successCallback + * @param {function} errorCallback + */ +chrome.cast.setCustomReceivers = function (receivers, successCallback, errorCallback) { + // TODO: Implement +}; + +/** + * Describes the state of a currently running Cast application. Normally, these objects should not be created by the client. + * @param {string} sessionId Uniquely identifies this instance of the receiver application. + * @param {string} appId The identifer of the Cast application. + * @param {string} displayName The human-readable name of the Cast application, for example, "YouTube". + * @param {chrome.cast.Image[]} appImages Array of images available describing the application. + * @param {chrome.cast.Receiver} receiver The receiver that is running the application. + * + * @property {Object} customData Custom data set by the receiver application. + * @property {chrome.cast.media.Media} media The media that belong to this Cast session, including those loaded by other senders. + * @property {Object[]} namespaces A list of the namespaces supported by the receiver application. + * @property {chrome.cast.SenderApplication} senderApps The sender applications supported by the receiver application. + * @property {string} statusText Descriptive text for the current application content, for example “My Wedding Slideshow”. + */ +chrome.cast.Session = function Session (sessionId, appId, displayName, appImages, receiver) { + EventEmitter.call(this); + this.sessionId = sessionId; + this.appId = appId; + this.displayName = displayName; + this.appImages = appImages || []; + this.receiver = receiver; + this.media = []; + this.status = chrome.cast.SessionStatus.CONNECTED; +}; + +chrome.cast.Session.prototype = Object.create(EventEmitter.prototype); + +function sessionPreCheck (sessionId) { + if (!_session || _session.status !== chrome.cast.SessionStatus.CONNECTED) { + return new chrome.cast.Error( + chrome.cast.ErrorCode.INVALID_PARAMETER, 'No active session'); + } + if (sessionId !== _session.sessionId) { + return new chrome.cast.Error( + chrome.cast.ErrorCode.INVALID_PARAMETER, 'Unknown session ID'); + } +} + +chrome.cast.Session.prototype._preCheck = function (errorCallback) { + var err = sessionPreCheck(this.sessionId); + if (err) { + errorCallback && errorCallback(err); + return err; + } +}; + +/** + * Sets the receiver volume. + * @param {number} newLevel The new volume level between 0.0 and 1.0. + * @param {function} successCallback + * @param {function} errorCallback The possible errors are TIMEOUT, API_NOT_INITIALIZED, INVALID_PARAMETER, CHANNEL_ERROR, and EXTENSION_MISSING. + */ +chrome.cast.Session.prototype.setReceiverVolumeLevel = function (newLevel, successCallback, errorCallback) { + if (this._preCheck(errorCallback)) { return; } + execute('setReceiverVolumeLevel', newLevel, function (err) { + if (!err) { + successCallback && successCallback(); + } else { + handleError(err, errorCallback); + } + }); +}; + +/** + * Sets the receiver volume. + * @param {boolean} muted The new muted status. + * @param {function} successCallback + * @param {function} errorCallback The possible errors are TIMEOUT, API_NOT_INITIALIZED, INVALID_PARAMETER, CHANNEL_ERROR, and EXTENSION_MISSING. + */ +chrome.cast.Session.prototype.setReceiverMuted = function (muted, successCallback, errorCallback) { + if (this._preCheck(errorCallback)) { return; } + execute('setReceiverMuted', muted, function (err) { + if (!err) { + successCallback && successCallback(); + } else { + handleError(err, errorCallback); + } + }); +}; + +/** + * Stops the running receiver application associated with the session. + * @param {function} successCallback + * @param {function} errorCallback The possible errors are TIMEOUT, API_NOT_INITIALIZED, CHANNEL_ERROR, and EXTENSION_MISSING. + */ +chrome.cast.Session.prototype.stop = function (successCallback, errorCallback) { + if (this._preCheck(errorCallback)) { return; } + execute('sessionStop', function (err) { + if (!err) { + successCallback && successCallback(); + } else { + if (err === chrome.cast.ErrorCode.INVALID_PARAMETER) { + errorCallback(new chrome.cast.Error(chrome.cast.Error.INVALID_PARAMETER, 'No active session', null)); + return; + } + handleError(err, errorCallback); + } + }); +}; + +/** + * Leaves the current session. + * @param {function} successCallback + * @param {function} errorCallback The possible errors are TIMEOUT, API_NOT_INITIALIZED, CHANNEL_ERROR, and EXTENSION_MISSING. + */ +chrome.cast.Session.prototype.leave = function (successCallback, errorCallback) { + if (this._preCheck(errorCallback)) { return; } + execute('sessionLeave', function (err) { + if (!err) { + successCallback && successCallback(); + } else { + if (err === chrome.cast.ErrorCode.INVALID_PARAMETER) { + errorCallback(new chrome.cast.Error(chrome.cast.Error.INVALID_PARAMETER, 'No active session', null)); + return; + } + handleError(err, errorCallback); + } + }); +}; + +/** + * Sends a message to the receiver application on the given namespace. + * The successCallback is invoked when the message has been submitted to the messaging channel. + * Delivery to the receiver application is best effort and not guaranteed. + * @param {string} namespace + * @param {Object or string} message Must not be null + * @param {[type]} successCallback Invoked when the message has been sent. Must not be null. + * @param {[type]} errorCallback Invoked on error. The possible errors are TIMEOUT, API_NOT_INITIALIZED, INVALID_PARAMETER, CHANNEL_ERROR, SESSION_ERROR, and EXTENSION_MISSING + */ +chrome.cast.Session.prototype.sendMessage = function (namespace, message, successCallback, errorCallback) { + if (this._preCheck(errorCallback)) { return; } + if (typeof message === 'object') { + message = JSON.stringify(message); + } + execute('sendMessage', namespace, message, function (err) { + if (!err) { + successCallback && successCallback(); + } else { + handleError(err, errorCallback); + } + }); +}; + +/** + * Request to load media. Must not be null. + * @param {chrome.cast.media.LoadRequest} loadRequest Request to load media. Must not be null. + * @param {function} successCallback Invoked with the loaded Media on success. + * @param {function} errorCallback Invoked on error. The possible errors are TIMEOUT, API_NOT_INITIALIZED, INVALID_PARAMETER, CHANNEL_ERROR, SESSION_ERROR, and EXTENSION_MISSING. + */ +chrome.cast.Session.prototype.loadMedia = function (loadRequest, successCallback, errorCallback) { + if (this._preCheck(errorCallback)) { return; } + var self = this; + + var mediaInfo = loadRequest.media; + execute('loadMedia', mediaInfo.contentId, mediaInfo.customData || {}, mediaInfo.contentType, mediaInfo.duration || 0.0, mediaInfo.streamType, loadRequest.autoplay || false, loadRequest.currentTime || 0, mediaInfo.metadata || {}, mediaInfo.textTrackSytle || {}, function (err, obj) { + if (!err) { + self._loadNewMedia(obj); + successCallback(self._getMedia()); + // Also trigger the update notification + self._emitMediaUpdated(obj.playerState !== 'IDLE'); + } else { + handleError(err, errorCallback); + } + }); +}; + +/** + * Loads and optionally starts playback of a new queue of media items into a + * running receiver application. + * @param {chrome.cast.media.QueueLoadRequest} loadRequest - Request to load a + * new queue of media items. Value must not be null. + * @param {function} successCallback Invoked with the loaded Media on success. + * @param {function} errorCallback Invoked on error. The possible errors + * are TIMEOUT, API_NOT_INITIALIZED, INVALID_PARAMETER, CHANNEL_ERROR, + * SESSION_ERROR, and EXTENSION_MISSING. + */ +chrome.cast.Session.prototype.queueLoad = function (loadRequest, successCallback, errorCallback) { + if (this._preCheck(errorCallback)) { return; } + if (!loadRequest.items || loadRequest.items.length === 0) { + return errorCallback && errorCallback(new chrome.cast.Error( + chrome.cast.ErrorCode.SESSION_ERROR, 'INVALID_PARAMS', + { reason: 'INVALID_PARAMS', type: 'INVALID_REQUEST' })); + } + var self = this; + + execute('queueLoad', loadRequest, function (err, obj) { + if (!err) { + self._loadNewMedia(obj); + successCallback(self._getMedia()); + // Also trigger the update notification + self._emitMediaUpdated(obj.playerState !== 'IDLE'); + } else { + handleError(err, errorCallback); + } + }); +}; + +/** + * Adds a listener that is invoked when the Session has changed. + * Changes to the following properties will trigger the listener: + * statusText, namespaces, status, and the volume of the receiver. + * + * Listeners should check the status property of the Session to + * determine its connection status. The boolean parameter isAlive is + * deprecated in favor of the status Session property. The isAlive + * parameter is still passed in for backwards compatibility, and is + * true unless status = chrome.cast.SessionStatus.STOPPED. + * @param {function} listener The listener to add. + */ +chrome.cast.Session.prototype.addUpdateListener = function (listener) { + this.on('_sessionUpdated', listener); +}; + +/** + * Removes a previously added listener for this Session. + * @param {function} listener The listener to remove. + */ +chrome.cast.Session.prototype.removeUpdateListener = function (listener) { + this.removeListener('_sessionUpdated', listener); +}; + +/** + * Adds a listener that is invoked when a message is received from the receiver application. + * The listener is invoked with the the namespace as the first argument and the message as the second argument. + * @param {string} namespace The namespace to listen on. + * @param {function} listener The listener to add. + */ +chrome.cast.Session.prototype.addMessageListener = function (namespace, listener) { + execute('addMessageListener', namespace); + this.on('message:' + namespace, listener); +}; + +/** + * Removes a previously added listener for messages. + * @param {string} namespace The namespace that is listened to. + * @param {function} listener The listener to remove. + */ +chrome.cast.Session.prototype.removeMessageListener = function (namespace, listener) { + this.removeListener('message:' + namespace, listener); +}; + +/** + * Adds a listener that is invoked when a media session is created by another sender. + * @param {function} listener The listener to add. + */ +chrome.cast.Session.prototype.addMediaListener = function (listener) { + this.on('_mediaListener', listener); +}; + +/** + * Removes a listener that was previously added with addMediaListener. + * @param {function} listener The listener to remove. + */ +chrome.cast.Session.prototype.removeMediaListener = function (listener) { + this.removeListener('_mediaListener', listener); +}; + +/** + * Updates the session with the new session information in obj. + */ +chrome.cast.Session.prototype._update = function (obj) { + var i; + for (var attr in obj) { + if (['receiver', 'media', 'appImages'].indexOf(attr) === -1) { + this[attr] = obj[attr]; + } + } + + if (obj.receiver) { + if (!this.receiver) { + this.receiver = new chrome.cast.Receiver(); + } + this.receiver._update(obj.receiver); + } else { + this.receiver = null; + } + + if (obj.media && obj.media.length > 0) { + this._updateMedia(obj.media[0]); + } else { + this._updateMedia(null); + } + + // Empty appImages + this.appImages = this.appImages || []; + this.appImages.splice(0, this.appImages.length); + if (obj.appImages && obj.appImages.length > 0) { + // refill appImages + for (i = 0; i < obj.appImages.length; i++) { + this.appImages.push(new chrome.cast.Image(obj.appImages[i].url)); + } + } +}; + +/** + * Updates the session's media array's 1st Media element with the + * new Media information in obj. + */ +chrome.cast.Session.prototype._updateMedia = function (obj) { + if (this.media && (!obj || JSON.stringify(obj) === '{}')) { + this.media.splice(0, _session.media.length); + return; + } + + if (this.media.length === 0) { + // Create the base media object because one doesn't exist + this.media.push(new chrome.cast.media.Media(obj.sessionId, obj.mediaSessionId)); + } + this._getMedia()._update(obj); +}; + +/** + * Empties the session's media array, and + * adds the new Media object described by media. + */ +chrome.cast.Session.prototype._loadNewMedia = function (media) { + // Remove previous media + this._updateMedia(null); + // Add the new media object + this._updateMedia(media); +}; + +chrome.cast.Session.prototype._emitMediaUpdated = function (isAlive) { + var media = this._getMedia(); + if (media) { + media.emit('_mediaUpdated', isAlive); + } +}; + +chrome.cast.Session.prototype._emitMediaListener = function () { + if (this._getMedia()) { + this.emit('_mediaListener', this._getMedia()); + } +}; + +chrome.cast.Session.prototype._getMedia = function () { + return this.media && this.media[0]; +}; + +/** + * The volume of a device or media stream. + * @param {number} level The current volume level as a value between 0.0 and 1.0. + * @param {boolean} muted Whether the receiver is muted, independent of the volume level. + */ +chrome.cast.Volume = function (level, muted) { + this.level = level; + if (muted || muted === false) { + this.muted = !!muted; + } +}; + +chrome.cast.Volume.prototype._update = function (jsonObj) { + for (var attr in jsonObj) { + this[attr] = jsonObj[attr]; + } +}; + +/** + * Describes the receiver running an application. Normally, these objects should not be created by the client. + * @param {string} label An identifier for the receiver that is unique to the browser profile and the origin of the API client. + * @param {string} friendlyName The user given name for the receiver. + * @param {chrome.cast.Capability[]} capabilities The capabilities of the receiver, for example audio and video. + * @param {chrome.cast.Volume} volume The current volume of the receiver. + */ +chrome.cast.Receiver = function (label, friendlyName, capabilities, volume) { + this.label = label; + this.friendlyName = friendlyName; + this.capabilities = capabilities || []; + this.volume = volume || null; + this.receiverType = chrome.cast.ReceiverType.CAST; + this.isActiveInput = null; +}; + +chrome.cast.Receiver.prototype._update = function (jsonObj) { + for (var attr in jsonObj) { + if (['volume'].indexOf(attr) === -1) { + this[attr] = jsonObj[attr]; + } + } + if (jsonObj.volume) { + if (!this.volume) { + this.volume = new chrome.cast.Volume(); + } + this.volume._update(jsonObj.volume); + } +}; + +/** + * Describes track metadata information + * @param {number} trackId Unique identifier of the track within the context of a chrome.cast.media.MediaInfo objects + * @param {chrome.cast.media.TrackType} trackType The type of track. Value must not be null. + */ +chrome.cast.media.Track = function Track (trackId, trackType) { + this.trackId = trackId; + this.type = trackType; + this.customData = this.language = this.name = this.subtype = this.trackContentId = this.trackContentType = null; +}; + +chrome.cast.media.Track.prototype._update = function (jsonObj) { + for (var attr in jsonObj) { + this[attr] = jsonObj[attr]; + } +}; + +/** + * Describes a media item. + * @param {string} contentId Identifies the content. + * @param {string} contentType MIME content type of the media. + * @property {Object} customData Custom data set by the receiver application. + * @property {number} duration Duration of the content, in seconds. + * @property {any type} metadata Describes the media content. + * @property {chrome.cast.media.StreamType} streamType The type of media stream. + */ +chrome.cast.media.MediaInfo = function MediaInfo (contentId, contentType) { + this.contentId = contentId; + this.streamType = chrome.cast.media.StreamType.BUFFERED; + this.contentType = contentType; + this.customData = this.duration = this.metadata = null; +}; + +chrome.cast.media.MediaInfo.prototype._update = function (jsonObj) { + var i; + for (var attr in jsonObj) { + if (['tracks', 'images'].indexOf(attr) === -1) { + this[attr] = jsonObj[attr]; + } + } + + if (jsonObj.tracks) { + this.tracks = []; + var track, t; + for (i = 0; i < jsonObj.tracks.length; i++) { + track = jsonObj.tracks[i]; + t = new chrome.cast.media.Track(); + t._update(track); + this.tracks.push(t); + } + } else { + this.tracks = null; + } + + // Empty images + this.images = this.images || []; + this.images.splice(0, this.images.length); + if (jsonObj.images && jsonObj.images.length > 0) { + // refill appImages + for (i = 0; i < jsonObj.images.length; i++) { + this.images.push(new chrome.cast.Image(jsonObj.images[i].url)); + } + } +}; + +/** + * Represents a media item that has been loaded into the receiver application. + * @param {string} sessionId Identifies the session that is hosting the media. + * @param {number} mediaSessionId Identifies the media item. + * + * @property {Object} customData Custom data set by the receiver application. + * @property {number} currentTime The current playback position in seconds since the start of the media. + * @property {chrome.cast.media.MediaInfo} media Media description. + * @property {number} playbackRate The playback rate. + * @property {chrome.cast.media.PlayerState} playerState The player state. + * @property {chrome.cast.media.MediaCommand[]} supportedMediaCommands The media commands supported by the media player. + * @property {chrome.cast.Volume} volume The media stream volume. + * @property {string} idleReason Reason for idling + */ +chrome.cast.media.Media = function Media (sessionId, mediaSessionId) { + EventEmitter.call(this); + this.sessionId = sessionId; + this.mediaSessionId = mediaSessionId; + this.currentTime = 0; + this.playbackRate = 1; + this.playerState = chrome.cast.media.PlayerState.IDLE; + this.idleReason = null; + this.supportedMediaCommands = [ + chrome.cast.media.MediaCommand.PAUSE, + chrome.cast.media.MediaCommand.SEEK, + chrome.cast.media.MediaCommand.STREAM_VOLUME, + chrome.cast.media.MediaCommand.STREAM_MUTE + ]; + this.volume = new chrome.cast.Volume(1, false); + this._lastUpdatedTime = Date.now(); + this.media = null; + this.queueData = undefined; +}; + +chrome.cast.media.Media.prototype = Object.create(EventEmitter.prototype); + +function mediaPreCheck (media) { + var err = sessionPreCheck(media.sessionId); + if (err) { + return err; + } + var currentMedia = _session._getMedia(); + if (!currentMedia || + media.sessionId !== currentMedia.sessionId || + media.mediaSessionId !== currentMedia.mediaSessionId || + media.playerState === chrome.cast.media.PlayerState.IDLE) { + return new chrome.cast.Error( + chrome.cast.ErrorCode.SESSION_ERROR, 'INVALID_MEDIA_SESSION_ID', + { reason: 'INVALID_MEDIA_SESSION_ID', type: 'INVALID_REQUEST' }); + } +} + +chrome.cast.media.Media.prototype._preCheck = function (errorCallback) { + var err = mediaPreCheck(this); + if (err) { + errorCallback && errorCallback(err); + return err; + } +}; + +/** + * Plays the media item. + * @param {chrome.cast.media.PlayRequest} playRequest The optional media play request. + * @param {function} successCallback Invoked on success. + * @param {function} errorCallback Invoked on error. The possible errors are TIMEOUT, API_NOT_INITIALIZED, INVALID_PARAMETER, CHANNEL_ERROR, SESSION_ERROR, and EXTENSION_MISSING. + */ +chrome.cast.media.Media.prototype.play = function (playRequest, successCallback, errorCallback) { + if (this._preCheck(errorCallback)) { return; } + execute('mediaPlay', function (err) { + if (!err) { + successCallback && successCallback(); + } else { + handleError(err, errorCallback); + } + }); +}; + +/** + * Pauses the media item. + * @param {chrome.cast.media.PauseRequest} pauseRequest The optional media pause request. + * @param {function} successCallback Invoked on success. + * @param {function} errorCallback Invoked on error. The possible errors are TIMEOUT, API_NOT_INITIALIZED, INVALID_PARAMETER, CHANNEL_ERROR, SESSION_ERROR, and EXTENSION_MISSING. + */ +chrome.cast.media.Media.prototype.pause = function (pauseRequest, successCallback, errorCallback) { + if (this._preCheck(errorCallback)) { return; } + execute('mediaPause', function (err) { + if (!err) { + successCallback && successCallback(); + } else { + handleError(err, errorCallback); + } + }); +}; + +/** + * Seeks the media item. + * @param {chrome.cast.media.SeekRequest} seekRequest The media seek request. Must not be null. + * @param {function} successCallback Invoked on success. + * @param {function} errorCallback Invoked on error. The possible errors are TIMEOUT, API_NOT_INITIALIZED, INVALID_PARAMETER, CHANNEL_ERROR, SESSION_ERROR, and EXTENSION_MISSING. + */ +chrome.cast.media.Media.prototype.seek = function (seekRequest, successCallback, errorCallback) { + if (this._preCheck(errorCallback)) { return; } + const currentTime = Math.round(seekRequest.currentTime); + const resumeState = seekRequest.resumeState || ''; + + execute('mediaSeek', currentTime, resumeState, function (err) { + if (!err) { + successCallback && successCallback(); + } else { + handleError(err, errorCallback); + } + }); +}; + +/** + * Stops the media player. + * @param {chrome.cast.media.StopRequest} stopRequest The media stop request. + * @param {function} successCallback Invoked on success. + * @param {function} errorCallback Invoked on error. The possible errors are TIMEOUT, API_NOT_INITIALIZED, INVALID_PARAMETER, CHANNEL_ERROR, SESSION_ERROR, and EXTENSION_MISSING. + */ +chrome.cast.media.Media.prototype.stop = function (stopRequest, successCallback, errorCallback) { + if (this._preCheck(errorCallback)) { return; } + execute('mediaStop', function (err) { + if (!err) { + successCallback && successCallback(); + } else { + handleError(err, errorCallback); + } + }); +}; + +/** + * Sets the media stream volume. At least one of volumeRequest.level or volumeRequest.muted must be set. Changing the mute state does not affect the volume level, and vice versa. + * @param {chrome.cast.media.VolumeRequest} volumeRequest The set volume request. Must not be null. + * @param {function} successCallback Invoked on success. + * @param {function} errorCallback Invoked on error. The possible errors are TIMEOUT, API_NOT_INITIALIZED, INVALID_PARAMETER, CHANNEL_ERROR, SESSION_ERROR, and EXTENSION_MISSING. + */ +chrome.cast.media.Media.prototype.setVolume = function (volumeRequest, successCallback, errorCallback) { + if (this._preCheck(errorCallback)) { return; } + if (!volumeRequest.volume || (volumeRequest.volume.level == null && volumeRequest.volume.muted === null)) { + errorCallback(new chrome.cast.Error(chrome.cast.ErrorCode.SESSION_ERROR), 'INVALID_PARAMS', { reason: 'INVALID_PARAMS', type: 'INVALID_REQUEST' }); + return; + } + + execute('setMediaVolume', volumeRequest.volume.level, volumeRequest.volume.muted, function (err) { + if (!err) { + successCallback && successCallback(); + } else { + handleError(err, errorCallback); + } + }); +}; + +/** + * Determines whether the media player supports the given media command. + * @param {chrome.cast.media.MediaCommand} command The command to query. Must not be null. + * @returns {boolean} True if the player supports the command. + */ +chrome.cast.media.Media.prototype.supportsCommand = function (command) { + return this.supportsCommands.indexOf(command) > -1; +}; + +/** + * Estimates the current playback position. + * @returns {number} number An estimate of the current playback position in seconds since the start of the media. + */ +chrome.cast.media.Media.prototype.getEstimatedTime = function () { + if (this.playerState === chrome.cast.media.PlayerState.PLAYING) { + var elapsed = (Date.now() - this._lastUpdatedTime) / 1000; + var estimatedTime = this.currentTime + elapsed; + + return estimatedTime; + } else { + return this.currentTime; + } +}; + +/** + * Modifies the text tracks style or change the tracks status. If a trackId does not match + * the existing trackIds the whole request will fail and no status will change. + * @param {chrome.cast.media.EditTracksInfoRequest} editTracksInfoRequest Value must not be null. + * @param {function()} successCallback Invoked on success. + * @param {function(not-null chrome.cast.Error)} errorCallback Invoked on error. The possible errors are TIMEOUT, API_NOT_INITIALIZED, INVALID_PARAMETER, CHANNEL_ERROR, SESSION_ERROR, and EXTENSION_MISSING. + **/ +chrome.cast.media.Media.prototype.editTracksInfo = function (editTracksInfoRequest, successCallback, errorCallback) { + if (this._preCheck(errorCallback)) { return; } + var activeTracks = editTracksInfoRequest.activeTrackIds; + var textTrackSytle = editTracksInfoRequest.textTrackSytle; + + execute('mediaEditTracksInfo', activeTracks, textTrackSytle || {}, function (err) { + if (!err) { + successCallback && successCallback(); + } else { + handleError(err, errorCallback); + } + }); +}; + +/** + * Plays the item with itemId in the queue. + * If itemId is not found in the queue, either because it wasn't there + * originally or it was removed by another sender before calling this function, + * this function will silently return without sending a request to the + * receiver. + * + * @param {number} itemId The ID of the item to which to jump. + * Value must not be null. + * @param {function()} successCallback Invoked on success. + * @param {function(not-null chrome.cast.Error)} errorCallback Invoked on error. The possible errors are TIMEOUT, API_NOT_INITIALIZED, INVALID_PARAMETER, CHANNEL_ERROR, SESSION_ERROR, and EXTENSION_MISSING. + **/ +chrome.cast.media.Media.prototype.queueJumpToItem = function (itemId, successCallback, errorCallback) { + if (this._preCheck(errorCallback)) { return; } + var isValidItemId = false; + for (var i = 0; i < this.items.length; i++) { + if (this.items[i].itemId === itemId) { + isValidItemId = true; + break; + } + } + if (!isValidItemId) { + return; + } + + execute('queueJumpToItem', itemId, function (err) { + if (!err) { + successCallback && successCallback(); + } else { + handleError(err, errorCallback); + } + }); +}; + +/** + * Adds a listener that is invoked when the status of the media has changed. + * Changes to the following properties will trigger the listener: currentTime, volume, metadata, playbackRate, playerState, customData. + * @param {function} listener The listener to add. The parameter indicates whether the Media object is still alive. + */ +chrome.cast.media.Media.prototype.addUpdateListener = function (listener) { + this.on('_mediaUpdated', listener); +}; + +/** + * Removes a previously added listener for this Media. + * @param {function} listener The listener to remove. + */ +chrome.cast.media.Media.prototype.removeUpdateListener = function (listener) { + this.removeListener('_mediaUpdated', listener); +}; + +chrome.cast.media.Media.prototype._update = function (obj) { + for (var attr in obj) { + if (['media', 'volume'].indexOf(attr) === -1) { + this[attr] = obj[attr]; + } + } + + if (obj.media) { + if (!this.media) { + this.media = new chrome.cast.media.MediaInfo(); + } + this.media._update(obj.media); + } + + if (obj.volume) { + if (!this.volume) { + this.volume = new chrome.cast.Volume(); + } + this.volume._update(obj.volume); + } + + this._lastUpdatedTime = Date.now(); +}; + +execute('setup', function (err, args) { + if (err) { + throw new Error('cordova-plugin-chromecast: Unable to setup chrome.cast API' + err); + } + if (args === 'OK') { + return; + } + + var eventName = args[0]; + args = args[1]; + var events = { + SETUP: function () { + chrome.cast.isAvailable = true; + }, + RECEIVER_LISTENER: function (available) { + if (!_receiverListener) { + return; + } + if (available) { + _receiverListener(chrome.cast.ReceiverAvailability.AVAILABLE); + } else { + _receiverListener(chrome.cast.ReceiverAvailability.UNAVAILABLE); + } + }, + /** + * Function called from cordova when the Session has changed. + * Changes to the following properties will trigger the listener: + * statusText, namespaces, status, and the volume of the receiver. + * + * Listeners should check the status property of the Session to + * determine its connection status. The boolean parameter isAlive is + * deprecated in favor of the status Session property. The isAlive + * parameter is still passed in for backwards compatibility, and is + * true unless status = chrome.cast.SessionStatus.STOPPED. + * @param {function} listener The listener to add. + */ + SESSION_UPDATE: function (obj) { + // Should we reset the session? + if (!obj) { + _session = undefined; + _sessionListener = undefined; + _receiverListener = undefined; + return; + } + if (_session) { + _session._update(obj); + _session.emit('_sessionUpdated', _session.status !== chrome.cast.SessionStatus.STOPPED); + } + }, + MEDIA_UPDATE: function (media) { + if (!_session) { + return; + } + _session._updateMedia(media); + _session._emitMediaUpdated(media ? !!media.isAlive : false); + }, + MEDIA_LOAD: function (media) { + if (_session) { + // Add new media + _session._loadNewMedia(media); + _session._emitMediaListener(); + } + }, + SESSION_LISTENER: function (javaSession) { + _session = createNewSession(javaSession); + _sessionListener && _sessionListener(_session); + }, + RECEIVER_MESSAGE: function (namespace, message) { + if (_session) { + _session.emit('message:' + namespace, namespace, message); + } + } + }; + + var event = events[eventName]; + if (!event) { + throw new Error('cordova-plugin-chromecast: No event called "' + eventName + '".'); + } + event.apply(null, args); +}); + +window.chrome.cast = chrome.cast; +console.info('Applied custom cast interface to window'); + +/** + * Updates the current session with the incoming javaSession + */ +function createNewSession (javaSession) { + _session = new chrome.cast.Session(); + _session._update(javaSession); + return _session; +} + +function execute (action) { + var args = [].slice.call(arguments); + args.shift(); + var callback; + if (args[args.length - 1] instanceof Function) { + callback = args.pop(); + } + + // Reasons to not execute + if (action !== 'setup' && !chrome.cast.isAvailable) { + return callback && callback(new chrome.cast.Error(chrome.cast.ErrorCode.API_NOT_INITIALIZED), 'The API is not initialized.', {}); + } + if (action !== 'setup' && action !== 'initialize' && !_initialized) { + throw new Error('Not initialized. Must call chrome.cast.initialize first.'); + } + + console.debug('execCast', action); + window.NativeShell.execCast(action, args, callback); +} + +function handleError (err, callback) { + var desc = err && err.description; + err = (err.code || err).toLowerCase(); + + if (err === chrome.cast.ErrorCode.TIMEOUT) { + desc = desc || 'The operation timed out.'; + } else if (err === chrome.cast.ErrorCode.INVALID_PARAMETER) { + desc = desc || 'The parameters to the operation were not valid.'; + } else if (err === chrome.cast.ErrorCode.RECEIVER_UNAVAILABLE) { + desc = desc || 'No receiver was compatible with the session request.'; + } else if (err === chrome.cast.ErrorCode.CANCEL) { + desc = desc || 'The operation was canceled by the user.'; + } else if (err === chrome.cast.ErrorCode.CHANNEL_ERROR) { + desc = desc || 'A channel to the receiver is not available.'; + } else if (err === chrome.cast.ErrorCode.SESSION_ERROR) { + desc = desc || 'A session could not be created, or a session was invalid.'; + } else { + desc = err + ' ' + desc; + err = chrome.cast.ErrorCode.UNKNOWN; + } + + var error = new chrome.cast.Error(err, desc, {}); + try { + console.error('Encountered cast error', JSON.stringify(error)); + } catch { + console.error('Encountered cast error', error); + } + + if (callback) { + callback(error); + } +} diff --git a/app/src/main/assets/native/injectionScript.js b/app/src/main/assets/native/injectionScript.js new file mode 100644 index 0000000..7704ed2 --- /dev/null +++ b/app/src/main/assets/native/injectionScript.js @@ -0,0 +1,15 @@ +(() => { + const scripts = [ + '/native/nativeshell.js', + '/native/EventEmitter.js', + document.currentScript.src.concat('?deferred=true&ts=', Date.now()) + ]; + for (const script of scripts) { + const scriptElement = document.createElement('script'); + scriptElement.src = script; + scriptElement.charset = 'utf-8'; + scriptElement.setAttribute('defer', ''); + document.body.appendChild(scriptElement); + } + document.currentScript.remove(); +})(); diff --git a/app/src/main/assets/native/nativeshell.js b/app/src/main/assets/native/nativeshell.js new file mode 100644 index 0000000..9a9ae00 --- /dev/null +++ b/app/src/main/assets/native/nativeshell.js @@ -0,0 +1,189 @@ +const features = [ + "filedownload", + "displaylanguage", + "subtitleappearancesettings", + "subtitleburnsettings", + //'sharing', + "exit", + "htmlaudioautoplay", + "htmlvideoautoplay", + "externallinks", + "clientsettings", + "multiserver", + "physicalvolumecontrol", + "remotecontrol", + "castmenuhashchange" +]; + +const plugins = [ + 'NavigationPlugin', + 'ExoPlayerPlugin', + 'ExternalPlayerPlugin' +]; + +// Add plugin loaders +for (const plugin of plugins) { + window[plugin] = async () => { + const pluginDefinition = await import(`/native/${plugin}.js`); + return pluginDefinition[plugin]; + }; +} + +let deviceId; +let deviceName; +let appName; +let appVersion; + +window.NativeShell = { + enableFullscreen() { + window.NativeInterface.enableFullscreen(); + }, + + disableFullscreen() { + window.NativeInterface.disableFullscreen(); + }, + + openUrl(url, target) { + window.NativeInterface.openUrl(url); + }, + + updateMediaSession(mediaInfo) { + window.NativeInterface.updateMediaSession(JSON.stringify(mediaInfo)); + }, + + hideMediaSession() { + window.NativeInterface.hideMediaSession(); + }, + + updateVolumeLevel(value) { + window.NativeInterface.updateVolumeLevel(value); + }, + + downloadFile(downloadInfo) { + window.NativeInterface.downloadFiles(JSON.stringify([downloadInfo])); + }, + + downloadFiles(downloadInfo) { + window.NativeInterface.downloadFiles(JSON.stringify(downloadInfo)); + }, + + openClientSettings() { + window.NativeInterface.openClientSettings(); + }, + + selectServer() { + window.NativeInterface.openServerSelection(); + }, + + getPlugins() { + return plugins; + }, + + async execCast(action, args, callback) { + this.castCallbacks = this.castCallbacks || {}; + this.castCallbacks[action] = callback; + window.NativeInterface.execCast(action, JSON.stringify(args)); + }, + + async castCallback(action, keep, err, result) { + const callbacks = this.castCallbacks || {}; + const callback = callbacks[action]; + callback && callback(err || null, result); + if (!keep) { + delete callbacks[action]; + } + } +}; + +function getDeviceProfile(profileBuilder, item) { + const profile = profileBuilder({ + enableMkvProgressive: false + }); + + profile.CodecProfiles = profile.CodecProfiles.filter(function (i) { + return i.Type === "Audio"; + }); + + profile.CodecProfiles.push({ + Type: "Video", + Container: "avi", + Conditions: [ + { + Condition: "NotEquals", + Property: "VideoCodecTag", + Value: "xvid" + } + ] + }); + + profile.CodecProfiles.push({ + Type: "Video", + Codec: "h264", + Conditions: [ + { + Condition: "EqualsAny", + Property: "VideoProfile", + Value: "high|main|baseline|constrained baseline" + }, + { + Condition: "LessThanEqual", + Property: "VideoLevel", + Value: "41" + }] + }); + + profile.TranscodingProfiles.reduce(function (profiles, p) { + if (p.Type === "Video" && p.CopyTimestamps === true && p.VideoCodec === "h264") { + p.AudioCodec += ",ac3"; + profiles.push(p); + } + return profiles; + }, []); + + return profile; +} + +window.NativeShell.AppHost = { + init() { + try { + const result = JSON.parse(window.NativeInterface.getDeviceInformation()); + // set globally so they can be used elsewhere + deviceId = result.deviceId; + deviceName = result.deviceName; + appName = result.appName; + appVersion = result.appVersion; + + return Promise.resolve({ + deviceId, + deviceName, + appName, + appVersion, + }); + } catch (e) { + return Promise.reject(e); + } + }, + getDefaultLayout() { + return "mobile"; + }, + supports(command) { + return features.includes(command.toLowerCase()); + }, + getDeviceProfile, + getSyncProfile: getDeviceProfile, + deviceName() { + return deviceName; + }, + deviceId() { + return deviceId; + }, + appName() { + return appName; + }, + appVersion() { + return appVersion; + }, + exit() { + window.NativeInterface.exitApp(); + } +}; diff --git a/app/src/main/java/org/jellyfin/mobile/JellyfinApplication.kt b/app/src/main/java/org/jellyfin/mobile/JellyfinApplication.kt new file mode 100644 index 0000000..5acc9f1 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/JellyfinApplication.kt @@ -0,0 +1,41 @@ +package org.jellyfin.mobile + +import android.app.Application +import android.webkit.WebView +import org.jellyfin.mobile.app.apiModule +import org.jellyfin.mobile.app.applicationModule +import org.jellyfin.mobile.data.databaseModule +import org.jellyfin.mobile.utils.JellyTree +import org.jellyfin.mobile.utils.isWebViewSupported +import org.koin.android.ext.koin.androidContext +import org.koin.androidx.fragment.koin.fragmentFactory +import org.koin.core.context.startKoin +import timber.log.Timber + +@Suppress("unused") +class JellyfinApplication : Application() { + override fun onCreate() { + super.onCreate() + + // Setup logging + Timber.plant(JellyTree()) + + if (BuildConfig.DEBUG) { + // Enable WebView debugging + if (isWebViewSupported()) { + WebView.setWebContentsDebuggingEnabled(true) + } + } + + startKoin { + androidContext(this@JellyfinApplication) + fragmentFactory() + + modules( + applicationModule, + apiModule, + databaseModule, + ) + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/MainActivity.kt b/app/src/main/java/org/jellyfin/mobile/MainActivity.kt new file mode 100644 index 0000000..2db2b49 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/MainActivity.kt @@ -0,0 +1,207 @@ +package org.jellyfin.mobile + +import android.app.Service +import android.content.ComponentName +import android.content.Intent +import android.content.ServiceConnection +import android.os.Bundle +import android.os.IBinder +import android.provider.Settings +import android.view.OrientationEventListener +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.activity.addCallback +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.withStarted +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.jellyfin.mobile.events.ActivityEventHandler +import org.jellyfin.mobile.player.cast.Chromecast +import org.jellyfin.mobile.player.cast.IChromecast +import org.jellyfin.mobile.player.ui.PlayerFragment +import org.jellyfin.mobile.setup.ConnectFragment +import org.jellyfin.mobile.utils.AndroidVersion +import org.jellyfin.mobile.utils.BackPressInterceptor +import org.jellyfin.mobile.utils.BluetoothPermissionHelper +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.utils.PermissionRequestHelper +import org.jellyfin.mobile.utils.SmartOrientationListener +import org.jellyfin.mobile.utils.extensions.replaceFragment +import org.jellyfin.mobile.utils.isWebViewSupported +import org.jellyfin.mobile.webapp.RemotePlayerService +import org.jellyfin.mobile.webapp.WebViewFragment +import org.koin.android.ext.android.get +import org.koin.android.ext.android.inject +import org.koin.androidx.fragment.android.setupKoinFragmentFactory +import org.koin.androidx.viewmodel.ext.android.viewModel + +class MainActivity : AppCompatActivity() { + private val activityEventHandler: ActivityEventHandler = get() + val mainViewModel: MainViewModel by viewModel() + val bluetoothPermissionHelper: BluetoothPermissionHelper = BluetoothPermissionHelper(this, get()) + val chromecast: IChromecast = Chromecast() + private val permissionRequestHelper: PermissionRequestHelper by inject() + + var serviceBinder: RemotePlayerService.ServiceBinder? = null + private set + private val serviceConnection: ServiceConnection = object : ServiceConnection { + override fun onServiceConnected(componentName: ComponentName, binder: IBinder) { + serviceBinder = binder as? RemotePlayerService.ServiceBinder + } + + override fun onServiceDisconnected(componentName: ComponentName) { + serviceBinder = null + } + } + + private val orientationListener: OrientationEventListener by lazy { SmartOrientationListener(this) } + + /** + * Passes back press events onto the currently visible [Fragment] if it implements the [BackPressInterceptor] interface. + * + * If the current fragment does not implement [BackPressInterceptor] or has decided not to intercept the event + * (see result of [BackPressInterceptor.onInterceptBackPressed]), the topmost backstack entry will be popped. + * + * If there is no topmost backstack entry, the event will be passed onto the dispatcher's fallback handler. + */ + private val onBackPressedCallback: OnBackPressedCallback.() -> Unit = callback@{ + val currentFragment = supportFragmentManager.findFragmentById(R.id.fragment_container) + if (currentFragment is BackPressInterceptor && currentFragment.onInterceptBackPressed()) { + // Top fragment handled back press + return@callback + } + + // This is the same default action as in Activity.onBackPressed + if (!supportFragmentManager.isStateSaved && supportFragmentManager.popBackStackImmediate()) { + // Removed fragment from back stack + return@callback + } + + // Let the system handle the back press + isEnabled = false + // Make sure that we *really* call the fallback handler + assert(!onBackPressedDispatcher.hasEnabledCallbacks()) { + "MainActivity should be the lowest onBackPressCallback" + } + onBackPressedDispatcher.onBackPressed() + isEnabled = true // re-enable callback in case activity isn't finished + } + + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + setupKoinFragmentFactory() + enableEdgeToEdge() + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + // Check WebView support + if (!isWebViewSupported()) { + AlertDialog.Builder(this).apply { + setTitle(R.string.dialog_web_view_not_supported) + setMessage(R.string.dialog_web_view_not_supported_message) + setCancelable(false) + if (AndroidVersion.isAtLeastN) { + setNeutralButton(R.string.dialog_button_open_settings) { _, _ -> + startActivity(Intent(Settings.ACTION_WEBVIEW_SETTINGS)) + Toast.makeText(context, R.string.toast_reopen_after_change, Toast.LENGTH_LONG).show() + finishAfterTransition() + } + } + setNegativeButton(R.string.dialog_button_close_app) { _, _ -> + finishAfterTransition() + } + }.show() + return + } + + // Bind player service + bindService(Intent(this, RemotePlayerService::class.java), serviceConnection, Service.BIND_AUTO_CREATE) + + // Subscribe to activity events + with(activityEventHandler) { subscribe() } + + // Load UI + lifecycleScope.launch { + mainViewModel.serverState.collectLatest { state -> + lifecycle.withStarted { + handleServerState(state) + } + } + } + + // Handle back presses + onBackPressedDispatcher.addCallback(this, onBackPressed = onBackPressedCallback) + + // Setup Chromecast + chromecast.initializePlugin(this) + } + + override fun onStart() { + super.onStart() + orientationListener.enable() + } + + private fun handleServerState(state: ServerState) { + with(supportFragmentManager) { + val currentFragment = findFragmentById(R.id.fragment_container) + when (state) { + ServerState.Pending -> { + // TODO add loading indicator + } + is ServerState.Unset -> { + if (currentFragment !is ConnectFragment) { + replaceFragment() + } + } + is ServerState.Available -> { + if (currentFragment !is WebViewFragment || currentFragment.server != state.server) { + replaceFragment( + Bundle().apply { + putParcelable(Constants.FRAGMENT_WEB_VIEW_EXTRA_SERVER, state.server) + }, + ) + } + } + } + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + + permissionRequestHelper.handleRequestPermissionsResult(requestCode, permissions, grantResults) + } + + override fun onSupportNavigateUp(): Boolean { + onBackPressedDispatcher.onBackPressed() + return true + } + + override fun onUserLeaveHint() { + for (fragment in supportFragmentManager.fragments) { + if (fragment is PlayerFragment && fragment.isVisible) { + fragment.onUserLeaveHint() + } + } + } + + override fun onStop() { + super.onStop() + orientationListener.disable() + } + + override fun onDestroy() { + unbindService(serviceConnection) + chromecast.destroy() + super.onDestroy() + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/MainViewModel.kt b/app/src/main/java/org/jellyfin/mobile/MainViewModel.kt new file mode 100644 index 0000000..5d8348e --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/MainViewModel.kt @@ -0,0 +1,49 @@ +package org.jellyfin.mobile + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import org.jellyfin.mobile.app.ApiClientController +import org.jellyfin.mobile.data.entity.ServerEntity + +class MainViewModel( + app: Application, + private val apiClientController: ApiClientController, +) : AndroidViewModel(app) { + private val _serverState: MutableStateFlow = MutableStateFlow(ServerState.Pending) + val serverState: StateFlow get() = _serverState + + init { + viewModelScope.launch { + refreshServer() + } + } + + suspend fun switchServer(hostname: String) { + apiClientController.setupServer(hostname) + refreshServer() + } + + private suspend fun refreshServer() { + val serverEntity = apiClientController.loadSavedServer() + _serverState.value = serverEntity?.let { entity -> ServerState.Available(entity) } ?: ServerState.Unset + } + + /** + * Temporarily unset the selected server to be able to connect to a different one + */ + fun resetServer() { + _serverState.value = ServerState.Unset + } +} + +sealed class ServerState { + open val server: ServerEntity? = null + + object Pending : ServerState() + object Unset : ServerState() + class Available(override val server: ServerEntity) : ServerState() +} diff --git a/app/src/main/java/org/jellyfin/mobile/app/ApiClientController.kt b/app/src/main/java/org/jellyfin/mobile/app/ApiClientController.kt new file mode 100644 index 0000000..a6073d6 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/app/ApiClientController.kt @@ -0,0 +1,88 @@ +package org.jellyfin.mobile.app + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jellyfin.mobile.data.dao.ServerDao +import org.jellyfin.mobile.data.dao.UserDao +import org.jellyfin.mobile.data.entity.ServerEntity +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.DeviceInfo + +class ApiClientController( + private val appPreferences: AppPreferences, + private val jellyfin: Jellyfin, + private val apiClient: ApiClient, + private val serverDao: ServerDao, + private val userDao: UserDao, +) { + private val baseDeviceInfo: DeviceInfo + get() = jellyfin.options.deviceInfo!! + + /** + * Store server with [hostname] in the database. + */ + suspend fun setupServer(hostname: String) { + appPreferences.currentServerId = withContext(Dispatchers.IO) { + serverDao.getServerByHostname(hostname)?.id ?: serverDao.insert(hostname) + } + apiClient.update(baseUrl = hostname) + } + + suspend fun setupUser(serverId: Long, userId: String, accessToken: String) { + appPreferences.currentUserId = withContext(Dispatchers.IO) { + userDao.upsert(serverId, userId, accessToken) + } + configureApiClientUser(userId, accessToken) + } + + suspend fun loadSavedServer(): ServerEntity? { + val server = withContext(Dispatchers.IO) { + val serverId = appPreferences.currentServerId ?: return@withContext null + serverDao.getServer(serverId) + } + configureApiClientServer(server) + return server + } + + suspend fun loadSavedServerUser() { + val serverUser = withContext(Dispatchers.IO) { + val serverId = appPreferences.currentServerId ?: return@withContext null + val userId = appPreferences.currentUserId ?: return@withContext null + userDao.getServerUser(serverId, userId) + } + + configureApiClientServer(serverUser?.server) + + if (serverUser?.user?.accessToken != null) { + configureApiClientUser(serverUser.user.userId, serverUser.user.accessToken) + } else { + resetApiClientUser() + } + } + + suspend fun loadPreviouslyUsedServers(): List = withContext(Dispatchers.IO) { + serverDao.getAllServers().filterNot { server -> + server.id == appPreferences.currentServerId + } + } + + private fun configureApiClientServer(server: ServerEntity?) { + apiClient.update(baseUrl = server?.hostname) + } + + private fun configureApiClientUser(userId: String, accessToken: String) { + apiClient.update( + accessToken = accessToken, + // Append user id to device id to ensure uniqueness across sessions + deviceInfo = baseDeviceInfo.copy(id = baseDeviceInfo.id + userId), + ) + } + + private fun resetApiClientUser() { + apiClient.update( + accessToken = null, + deviceInfo = baseDeviceInfo, + ) + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/app/ApiModule.kt b/app/src/main/java/org/jellyfin/mobile/app/ApiModule.kt new file mode 100644 index 0000000..0824620 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/app/ApiModule.kt @@ -0,0 +1,19 @@ +package org.jellyfin.mobile.app + +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.createJellyfin +import org.jellyfin.sdk.model.ClientInfo +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module + +val apiModule = module { + // Jellyfin API builder and API client instance + single { + createJellyfin { + context = androidContext() + clientInfo = ClientInfo(name = Constants.APP_INFO_NAME, version = Constants.APP_INFO_VERSION) + } + } + single { get().createApi() } +} diff --git a/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt b/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt new file mode 100644 index 0000000..b9f56e4 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt @@ -0,0 +1,153 @@ +package org.jellyfin.mobile.app + +import android.content.Context +import androidx.core.net.toUri +import coil.ImageLoader +import com.google.android.exoplayer2.ext.cronet.CronetDataSource +import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory +import com.google.android.exoplayer2.extractor.ts.TsExtractor +import com.google.android.exoplayer2.source.DefaultMediaSourceFactory +import com.google.android.exoplayer2.source.MediaSource +import com.google.android.exoplayer2.source.ProgressiveMediaSource +import com.google.android.exoplayer2.source.SingleSampleMediaSource +import com.google.android.exoplayer2.source.hls.HlsMediaSource +import com.google.android.exoplayer2.upstream.DataSource +import com.google.android.exoplayer2.upstream.DataSpec +import com.google.android.exoplayer2.upstream.DefaultDataSource +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource +import com.google.android.exoplayer2.upstream.ResolvingDataSource +import com.google.android.exoplayer2.util.Util +import kotlinx.coroutines.channels.Channel +import okhttp3.OkHttpClient +import org.chromium.net.CronetEngine +import org.chromium.net.CronetProvider +import org.jellyfin.mobile.MainViewModel +import org.jellyfin.mobile.bridge.NativePlayer +import org.jellyfin.mobile.events.ActivityEventHandler +import org.jellyfin.mobile.player.audio.car.LibraryBrowser +import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder +import org.jellyfin.mobile.player.interaction.PlayerEvent +import org.jellyfin.mobile.player.qualityoptions.QualityOptionsProvider +import org.jellyfin.mobile.player.source.MediaSourceResolver +import org.jellyfin.mobile.player.ui.PlayerFragment +import org.jellyfin.mobile.setup.ConnectionHelper +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.utils.PermissionRequestHelper +import org.jellyfin.mobile.utils.isLowRamDevice +import org.jellyfin.mobile.webapp.RemoteVolumeProvider +import org.jellyfin.mobile.webapp.WebViewFragment +import org.jellyfin.mobile.webapp.WebappFunctionChannel +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder +import org.koin.android.ext.koin.androidApplication +import org.koin.androidx.fragment.dsl.fragment +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.qualifier.named +import org.koin.dsl.module +import java.util.concurrent.Executors + +const val PLAYER_EVENT_CHANNEL = "PlayerEventChannel" +private const val HTTP_CACHE_SIZE: Long = 16 * 1024 * 1024 +private const val TS_SEARCH_PACKETS = 1800 + +val applicationModule = module { + single { AppPreferences(androidApplication()) } + single { OkHttpClient() } + single { ImageLoader(androidApplication()) } + single { PermissionRequestHelper() } + single { RemoteVolumeProvider(get()) } + single(named(PLAYER_EVENT_CHANNEL)) { Channel() } + + // Controllers + single { ApiClientController(get(), get(), get(), get(), get()) } + + // Event handlers and channels + single { ActivityEventHandler(get()) } + single { WebappFunctionChannel() } + + // Bridge interfaces + single { NativePlayer(get(), get(), get(named(PLAYER_EVENT_CHANNEL))) } + + // ViewModels + viewModel { MainViewModel(get(), get()) } + + // Fragments + fragment { WebViewFragment() } + fragment { PlayerFragment() } + + // Connection helper + single { ConnectionHelper(get(), get()) } + + // Media player helpers + single { MediaSourceResolver(get()) } + single { DeviceProfileBuilder(get()) } + single { QualityOptionsProvider() } + + // ExoPlayer factories + single { + val context: Context = get() + val apiClient: ApiClient = get() + + val provider = CronetProvider.getAllProviders(context).firstOrNull { provider: CronetProvider -> + (provider.name == CronetProvider.PROVIDER_NAME_APP_PACKAGED) && provider.isEnabled + } + + val baseDataSourceFactory = if (provider != null) { + val cronetEngine = provider.createBuilder() + .enableHttp2(true) + .enableQuic(true) + .enableBrotli(true) + .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_IN_MEMORY, HTTP_CACHE_SIZE) + .build() + CronetDataSource.Factory(cronetEngine, Executors.newCachedThreadPool()).apply { + setUserAgent(Util.getUserAgent(context, Constants.APP_INFO_NAME)) + } + } else { + DefaultHttpDataSource.Factory().apply { + setUserAgent(Util.getUserAgent(context, Constants.APP_INFO_NAME)) + } + } + + val dataSourceFactory = DefaultDataSource.Factory(context, baseDataSourceFactory) + + // Add authorization header. This is needed as we don't pass the + // access token in the URL for Android Auto. + ResolvingDataSource.Factory(dataSourceFactory) { dataSpec: DataSpec -> + // Only send authorization header if URI matches the jellyfin server + val baseUrlAuthority = apiClient.baseUrl?.toUri()?.authority + + if (dataSpec.uri.authority == baseUrlAuthority) { + val authorizationHeaderString = AuthorizationHeaderBuilder.buildHeader( + clientName = apiClient.clientInfo.name, + clientVersion = apiClient.clientInfo.version, + deviceId = apiClient.deviceInfo.id, + deviceName = apiClient.deviceInfo.name, + accessToken = apiClient.accessToken, + ) + + dataSpec.withRequestHeaders(hashMapOf("Authorization" to authorizationHeaderString)) + } else { + dataSpec + } + } + } + single { + val context: Context = get() + val extractorsFactory = DefaultExtractorsFactory().apply { + // https://github.com/google/ExoPlayer/issues/8571 + setTsExtractorTimestampSearchBytes( + when { + !context.isLowRamDevice -> TS_SEARCH_PACKETS * TsExtractor.TS_PACKET_SIZE // 3x default + else -> TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES + }, + ) + } + DefaultMediaSourceFactory(get(), extractorsFactory) + } + single { ProgressiveMediaSource.Factory(get()) } + single { HlsMediaSource.Factory(get()) } + single { SingleSampleMediaSource.Factory(get()) } + + // Media components + single { LibraryBrowser(get(), get()) } +} diff --git a/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt b/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt new file mode 100644 index 0000000..e4d02a5 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt @@ -0,0 +1,127 @@ +package org.jellyfin.mobile.app + +import android.content.Context +import android.content.SharedPreferences +import android.os.Environment +import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE +import androidx.core.content.edit +import org.jellyfin.mobile.settings.ExternalPlayerPackage +import org.jellyfin.mobile.settings.VideoPlayerType +import org.jellyfin.mobile.utils.Constants +import java.io.File + +class AppPreferences(context: Context) { + private val sharedPreferences: SharedPreferences = + context.getSharedPreferences("${context.packageName}_preferences", Context.MODE_PRIVATE) + + var currentServerId: Long? + get() = sharedPreferences.getLong(Constants.PREF_SERVER_ID, -1).takeIf { it >= 0 } + set(value) { + sharedPreferences.edit { + if (value != null) putLong(Constants.PREF_SERVER_ID, value) else remove(Constants.PREF_SERVER_ID) + } + } + + var currentUserId: Long? + get() = sharedPreferences.getLong(Constants.PREF_USER_ID, -1).takeIf { it >= 0 } + set(value) { + sharedPreferences.edit { + if (value != null) putLong(Constants.PREF_USER_ID, value) else remove(Constants.PREF_USER_ID) + } + } + + var ignoreBatteryOptimizations: Boolean + get() = sharedPreferences.getBoolean(Constants.PREF_IGNORE_BATTERY_OPTIMIZATIONS, false) + set(value) { + sharedPreferences.edit { + putBoolean(Constants.PREF_IGNORE_BATTERY_OPTIMIZATIONS, value) + } + } + + var ignoreWebViewChecks: Boolean + get() = sharedPreferences.getBoolean(Constants.PREF_IGNORE_WEBVIEW_CHECKS, false) + set(value) { + sharedPreferences.edit { + putBoolean(Constants.PREF_IGNORE_WEBVIEW_CHECKS, value) + } + } + + var ignoreBluetoothPermission: Boolean + get() = sharedPreferences.getBoolean(Constants.PREF_IGNORE_BLUETOOTH_PERMISSION, false) + set(value) { + sharedPreferences.edit { + putBoolean(Constants.PREF_IGNORE_BLUETOOTH_PERMISSION, value) + } + } + + var downloadMethod: Int? + get() = sharedPreferences.getInt(Constants.PREF_DOWNLOAD_METHOD, -1).takeIf { it >= 0 } + set(value) { + if (value != null) { + sharedPreferences.edit { + putInt(Constants.PREF_DOWNLOAD_METHOD, value) + } + } + } + + var downloadLocation: String + get() { + val savedStorage = sharedPreferences.getString(Constants.PREF_DOWNLOAD_LOCATION, null) + if (savedStorage != null) { + if (File(savedStorage).parentFile?.isDirectory == true) { + // Saved location is still valid + return savedStorage + } else { + // Reset download option if corrupt + sharedPreferences.edit { + remove(Constants.PREF_DOWNLOAD_LOCATION) + } + } + } + + // Return default storage location + return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath + } + set(value) { + sharedPreferences.edit { + if (File(value).parentFile?.isDirectory == true) { + putString(Constants.PREF_DOWNLOAD_LOCATION, value) + } + } + } + + val musicNotificationAlwaysDismissible: Boolean + get() = sharedPreferences.getBoolean(Constants.PREF_MUSIC_NOTIFICATION_ALWAYS_DISMISSIBLE, false) + + @VideoPlayerType + val videoPlayerType: String + get() = sharedPreferences.getString(Constants.PREF_VIDEO_PLAYER_TYPE, VideoPlayerType.WEB_PLAYER)!! + + val exoPlayerStartLandscapeVideoInLandscape: Boolean + get() = sharedPreferences.getBoolean(Constants.PREF_EXOPLAYER_START_LANDSCAPE_VIDEO_IN_LANDSCAPE, false) + + val exoPlayerAllowSwipeGestures: Boolean + get() = sharedPreferences.getBoolean(Constants.PREF_EXOPLAYER_ALLOW_SWIPE_GESTURES, true) + + val exoPlayerRememberBrightness: Boolean + get() = sharedPreferences.getBoolean(Constants.PREF_EXOPLAYER_REMEMBER_BRIGHTNESS, false) + + var exoPlayerBrightness: Float + get() = sharedPreferences.getFloat(Constants.PREF_EXOPLAYER_BRIGHTNESS, BRIGHTNESS_OVERRIDE_NONE) + set(value) { + sharedPreferences.edit { + putFloat(Constants.PREF_EXOPLAYER_BRIGHTNESS, value) + } + } + + val exoPlayerAllowBackgroundAudio: Boolean + get() = sharedPreferences.getBoolean(Constants.PREF_EXOPLAYER_ALLOW_BACKGROUND_AUDIO, false) + + val exoPlayerDirectPlayAss: Boolean + get() = sharedPreferences.getBoolean(Constants.PREF_EXOPLAYER_DIRECT_PLAY_ASS, false) + + @ExternalPlayerPackage + var externalPlayerApp: String + get() = sharedPreferences.getString(Constants.PREF_EXTERNAL_PLAYER_APP, ExternalPlayerPackage.SYSTEM_DEFAULT)!! + set(value) = sharedPreferences.edit { putString(Constants.PREF_EXTERNAL_PLAYER_APP, value) } +} diff --git a/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt b/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt new file mode 100644 index 0000000..7537b46 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt @@ -0,0 +1,317 @@ +package org.jellyfin.mobile.bridge + +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.webkit.JavascriptInterface +import android.widget.Toast +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.text.isDigitsOnly +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import org.jellyfin.mobile.R +import org.jellyfin.mobile.app.AppPreferences +import org.jellyfin.mobile.player.PlayerException +import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder +import org.jellyfin.mobile.player.interaction.PlayOptions +import org.jellyfin.mobile.player.source.ExternalSubtitleStream +import org.jellyfin.mobile.player.source.JellyfinMediaSource +import org.jellyfin.mobile.player.source.MediaSourceResolver +import org.jellyfin.mobile.settings.ExternalPlayerPackage +import org.jellyfin.mobile.settings.VideoPlayerType +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.utils.isPackageInstalled +import org.jellyfin.mobile.utils.toast +import org.jellyfin.mobile.webapp.WebappFunctionChannel +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.videosApi +import org.jellyfin.sdk.api.operations.VideosApi +import org.jellyfin.sdk.model.api.DeviceProfile +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import org.json.JSONObject +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.component.inject +import timber.log.Timber + +class ExternalPlayer( + private val context: Context, + lifecycleOwner: LifecycleOwner, + registry: ActivityResultRegistry, +) : KoinComponent { + private val coroutinesScope = MainScope() + + private val appPreferences: AppPreferences by inject() + private val webappFunctionChannel: WebappFunctionChannel by inject() + private val mediaSourceResolver: MediaSourceResolver by inject() + private val deviceProfileBuilder: DeviceProfileBuilder by inject() + private val externalPlayerProfile: DeviceProfile = deviceProfileBuilder.getExternalPlayerProfile() + private val apiClient: ApiClient = get() + private val videosApi: VideosApi = apiClient.videosApi + + private val playerContract = registry.register( + "externalplayer", + lifecycleOwner, + ActivityResultContracts.StartActivityForResult(), + ) { result -> + val resultCode = result.resultCode + val intent = result.data + when (val action = intent?.action) { + Constants.MPV_PLAYER_RESULT_ACTION -> handleMPVPlayer(resultCode, intent) + Constants.MX_PLAYER_RESULT_ACTION -> handleMXPlayer(resultCode, intent) + Constants.VLC_PLAYER_RESULT_ACTION -> handleVLCPlayer(resultCode, intent) + else -> { + if (action != null && resultCode != Activity.RESULT_CANCELED) { + Timber.d("Unknown action $action [resultCode=$resultCode]") + notifyEvent(Constants.EVENT_CANCELED) + context.toast(R.string.external_player_not_supported_yet, Toast.LENGTH_LONG) + } else { + Timber.d("Playback canceled: no player selected or player without action result") + notifyEvent(Constants.EVENT_CANCELED) + context.toast(R.string.external_player_invalid_player, Toast.LENGTH_LONG) + } + } + } + } + + @JavascriptInterface + fun isEnabled() = appPreferences.videoPlayerType == VideoPlayerType.EXTERNAL_PLAYER + + @JavascriptInterface + fun initPlayer(args: String) { + val playOptions = PlayOptions.fromJson(JSONObject(args)) + val itemId = playOptions?.run { + ids.firstOrNull() ?: mediaSourceId?.toUUIDOrNull() // fallback if ids is empty + } + if (playOptions == null || itemId == null) { + context.toast(R.string.player_error_invalid_play_options) + return + } + + coroutinesScope.launch { + // Resolve media source to query info about external (subtitle) streams + mediaSourceResolver.resolveMediaSource( + itemId = itemId, + mediaSourceId = playOptions.mediaSourceId, + deviceProfile = externalPlayerProfile, + startTimeTicks = playOptions.startPositionTicks, + audioStreamIndex = playOptions.audioStreamIndex, + subtitleStreamIndex = playOptions.subtitleStreamIndex, + maxStreamingBitrate = Int.MAX_VALUE, // ensure we always direct play + autoOpenLiveStream = false, + ).onSuccess { jellyfinMediaSource -> + playMediaSource(playOptions, jellyfinMediaSource) + }.onFailure { error -> + when (error as? PlayerException) { + is PlayerException.InvalidPlayOptions -> context.toast(R.string.player_error_invalid_play_options) + is PlayerException.NetworkFailure -> context.toast(R.string.player_error_network_failure) + is PlayerException.UnsupportedContent -> context.toast(R.string.player_error_unsupported_content) + null -> throw error // Unknown error, rethrow from here + } + } + } + } + + private fun playMediaSource(playOptions: PlayOptions, source: JellyfinMediaSource) { + // Create direct play URL + val url = videosApi.getVideoStreamUrl( + itemId = source.itemId, + static = true, + mediaSourceId = source.id, + playSessionId = source.playSessionId, + ) + + // Select correct subtitle + val selectedSubtitleStream = playOptions.subtitleStreamIndex?.let { index -> + source.mediaStreams.getOrNull(index) + } + source.selectSubtitleStream(selectedSubtitleStream) + + // Build playback intent + val playerIntent = Intent(Intent.ACTION_VIEW).apply { + if (context.packageManager.isPackageInstalled(appPreferences.externalPlayerApp)) { + component = getComponent(appPreferences.externalPlayerApp) + } + setDataAndType(Uri.parse(url), "video/*") + putExtra("title", source.name) + putExtra("position", source.startTimeMs.toInt()) + putExtra("return_result", true) + putExtra("secure_uri", true) + + val externalSubs = source.externalSubtitleStreams + val enabledSubUrl = when { + source.selectedSubtitleStream != null -> { + externalSubs.find { stream -> stream.index == source.selectedSubtitleStream?.index }?.let { sub -> + apiClient.createUrl(sub.deliveryUrl) + } + } + else -> null + } + + // MX Player API / MPV + val subtitleUris = externalSubs.map { stream -> + Uri.parse(apiClient.createUrl(stream.deliveryUrl)) + } + putExtra("subs", subtitleUris.toTypedArray()) + putExtra("subs.name", externalSubs.map(ExternalSubtitleStream::displayTitle).toTypedArray()) + putExtra("subs.filename", externalSubs.map(ExternalSubtitleStream::language).toTypedArray()) + putExtra("subs.enable", enabledSubUrl?.let { url -> arrayOf(Uri.parse(url)) } ?: emptyArray()) + + // VLC + if (enabledSubUrl != null) putExtra("subtitles_location", enabledSubUrl) + } + playerContract.launch(playerIntent) + Timber.d( + "Starting playback [id=${source.itemId}, title=${source.name}, " + + "playMethod=${source.playMethod}, startTimeMs=${source.startTimeMs}]", + ) + } + + private fun notifyEvent(event: String, parameters: String = "") { + if (event in ALLOWED_EVENTS && parameters.isDigitsOnly()) { + webappFunctionChannel.call("window.ExtPlayer.notify$event($parameters)") + } + } + + // https://github.com/mpv-android/mpv-android/commit/f70298fe23c4872ea04fe4f2a8b378b986460d98 + private fun handleMPVPlayer(resultCode: Int, data: Intent) { + val player = "MPV Player" + when (resultCode) { + Activity.RESULT_OK -> { + val position = data.getIntExtra("position", 0) + if (position > 0) { + Timber.d("Playback stopped [player=$player, position=$position]") + notifyEvent(Constants.EVENT_TIME_UPDATE, "$position") + notifyEvent(Constants.EVENT_ENDED) + } else { + Timber.d("Playback completed [player=$player]") + notifyEvent(Constants.EVENT_TIME_UPDATE) + notifyEvent(Constants.EVENT_ENDED) + } + } + Activity.RESULT_CANCELED -> { + Timber.d("Playback stopped by unknown error [player=$player]") + notifyEvent(Constants.EVENT_CANCELED) + context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG) + } + else -> { + Timber.d("Invalid state [player=$player, resultCode=$resultCode]") + notifyEvent(Constants.EVENT_CANCELED) + context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG) + } + } + } + + // https://sites.google.com/site/mxvpen/api + private fun handleMXPlayer(resultCode: Int, data: Intent) { + val player = "MX Player" + when (resultCode) { + Activity.RESULT_OK -> { + when (val endBy = data.getStringExtra("end_by")) { + "playback_completion" -> { + Timber.d("Playback completed [player=$player]") + notifyEvent(Constants.EVENT_TIME_UPDATE) + notifyEvent(Constants.EVENT_ENDED) + } + "user" -> { + val position = data.getIntExtra("position", 0) + val duration = data.getIntExtra("duration", 0) + if (position > 0) { + Timber.d("Playback stopped [player=$player, position=$position, duration=$duration]") + notifyEvent(Constants.EVENT_TIME_UPDATE, "$position") + notifyEvent(Constants.EVENT_ENDED) + } else { + Timber.d("Invalid state [player=$player, position=$position, duration=$duration]") + notifyEvent(Constants.EVENT_CANCELED) + context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG) + } + } + else -> { + Timber.d("Invalid state [player=$player, endBy=$endBy]") + notifyEvent(Constants.EVENT_CANCELED) + context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG) + } + } + } + Activity.RESULT_CANCELED -> { + Timber.d("Playback stopped by user [player=$player]") + notifyEvent(Constants.EVENT_CANCELED) + } + Activity.RESULT_FIRST_USER -> { + Timber.d("Playback stopped by unknown error [player=$player]") + notifyEvent(Constants.EVENT_CANCELED) + context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG) + } + else -> { + Timber.d("Invalid state [player=$player, resultCode=$resultCode]") + notifyEvent(Constants.EVENT_CANCELED) + context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG) + } + } + } + + // https://wiki.videolan.org/Android_Player_Intents/ + private fun handleVLCPlayer(resultCode: Int, data: Intent) { + val player = "VLC Player" + when (resultCode) { + Activity.RESULT_OK -> { + val extraPosition = data.getLongExtra("extra_position", 0L) + val extraDuration = data.getLongExtra("extra_duration", 0L) + if (extraPosition > 0L) { + Timber.d( + "Playback stopped [player=$player, extraPosition=$extraPosition, extraDuration=$extraDuration]", + ) + notifyEvent(Constants.EVENT_TIME_UPDATE, "$extraPosition") + notifyEvent(Constants.EVENT_ENDED) + } else { + if (extraDuration == 0L && extraPosition == 0L) { + Timber.d("Playback completed [player=$player]") + notifyEvent(Constants.EVENT_TIME_UPDATE) + notifyEvent(Constants.EVENT_ENDED) + } else { + Timber.d( + "Invalid state [player=$player, extraPosition=$extraPosition, extraDuration=$extraDuration]", + ) + notifyEvent(Constants.EVENT_CANCELED) + context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG) + } + } + } + else -> { + Timber.d("Playback failed [player=$player, resultCode=$resultCode]") + notifyEvent(Constants.EVENT_CANCELED) + context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG) + } + } + } + + /** + * To ensure that the correct activity is called. + */ + private fun getComponent(@ExternalPlayerPackage packageName: String): ComponentName? { + return when (packageName) { + ExternalPlayerPackage.MPV_PLAYER -> { + ComponentName(packageName, "$packageName.MPVActivity") + } + ExternalPlayerPackage.MX_PLAYER_FREE, ExternalPlayerPackage.MX_PLAYER_PRO -> { + ComponentName(packageName, "$packageName.ActivityScreen") + } + ExternalPlayerPackage.VLC_PLAYER -> { + ComponentName(packageName, "$packageName.StartActivity") + } + else -> null + } + } + + companion object { + private val ALLOWED_EVENTS = arrayOf( + Constants.EVENT_CANCELED, + Constants.EVENT_ENDED, + Constants.EVENT_TIME_UPDATE, + ) + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/bridge/JavascriptCallback.kt b/app/src/main/java/org/jellyfin/mobile/bridge/JavascriptCallback.kt new file mode 100644 index 0000000..eae826d --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/bridge/JavascriptCallback.kt @@ -0,0 +1,23 @@ +package org.jellyfin.mobile.bridge + +import org.json.JSONArray +import org.json.JSONObject + +abstract class JavascriptCallback { + protected abstract fun callback(keep: Boolean, err: String?, result: String?) + + @JvmOverloads + fun success(keep: Boolean = false, result: String? = null) = callback(keep, null, result?.let { """"$it"""" }) + + @JvmOverloads + fun success(keep: Boolean = false, result: JSONObject?) = callback(keep, null, result.toString()) + + @JvmOverloads + fun success(keep: Boolean = false, result: JSONArray?) = callback(keep, null, result.toString()) + + @JvmOverloads + fun error(keep: Boolean = false, message: String) = callback(keep, """"$message"""", null) + + @JvmOverloads + fun error(keep: Boolean = false, error: JSONObject) = callback(keep, error.toString(), null) +} diff --git a/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt b/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt new file mode 100644 index 0000000..9b70aa3 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt @@ -0,0 +1,171 @@ +package org.jellyfin.mobile.bridge + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.media.session.PlaybackState +import android.net.Uri +import android.webkit.JavascriptInterface +import androidx.core.content.ContextCompat +import org.jellyfin.mobile.events.ActivityEvent +import org.jellyfin.mobile.events.ActivityEventHandler +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.utils.Constants.EXTRA_ALBUM +import org.jellyfin.mobile.utils.Constants.EXTRA_ARTIST +import org.jellyfin.mobile.utils.Constants.EXTRA_CAN_SEEK +import org.jellyfin.mobile.utils.Constants.EXTRA_DURATION +import org.jellyfin.mobile.utils.Constants.EXTRA_IMAGE_URL +import org.jellyfin.mobile.utils.Constants.EXTRA_IS_LOCAL_PLAYER +import org.jellyfin.mobile.utils.Constants.EXTRA_IS_PAUSED +import org.jellyfin.mobile.utils.Constants.EXTRA_ITEM_ID +import org.jellyfin.mobile.utils.Constants.EXTRA_PLAYER_ACTION +import org.jellyfin.mobile.utils.Constants.EXTRA_POSITION +import org.jellyfin.mobile.utils.Constants.EXTRA_TITLE +import org.jellyfin.mobile.webapp.RemotePlayerService +import org.jellyfin.mobile.webapp.RemoteVolumeProvider +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.component.inject +import timber.log.Timber + +@Suppress("unused") +class NativeInterface(private val context: Context) : KoinComponent { + private val activityEventHandler: ActivityEventHandler = get() + private val remoteVolumeProvider: RemoteVolumeProvider by inject() + + @SuppressLint("HardwareIds") + @JavascriptInterface + fun getDeviceInformation(): String? = try { + val apiClient: ApiClient = get() + val deviceInfo = apiClient.deviceInfo + val clientInfo = apiClient.clientInfo + + JSONObject().apply { + put("deviceId", deviceInfo.id) + // normalize the name by removing special characters + // and making sure it's at least 1 character long + // otherwise the webui will fail to send it to the server + val name = AuthorizationHeaderBuilder.encodeParameterValue(deviceInfo.name).padStart(1) + put("deviceName", name) + put("appName", clientInfo.name) + put("appVersion", clientInfo.version) + }.toString() + } catch (e: JSONException) { + null + } + + @JavascriptInterface + fun enableFullscreen(): Boolean { + emitEvent(ActivityEvent.ChangeFullscreen(true)) + return true + } + + @JavascriptInterface + fun disableFullscreen(): Boolean { + emitEvent(ActivityEvent.ChangeFullscreen(false)) + return true + } + + @JavascriptInterface + fun openUrl(uri: String): Boolean { + emitEvent(ActivityEvent.OpenUrl(uri)) + return true + } + + @JavascriptInterface + fun updateMediaSession(args: String): Boolean { + val options = try { + JSONObject(args) + } catch (e: JSONException) { + Timber.e("updateMediaSession: %s", e.message) + return false + } + val intent = Intent(context, RemotePlayerService::class.java).apply { + action = Constants.ACTION_REPORT + putExtra(EXTRA_PLAYER_ACTION, options.optString(EXTRA_PLAYER_ACTION)) + putExtra(EXTRA_ITEM_ID, options.optString(EXTRA_ITEM_ID)) + putExtra(EXTRA_TITLE, options.optString(EXTRA_TITLE)) + putExtra(EXTRA_ARTIST, options.optString(EXTRA_ARTIST)) + putExtra(EXTRA_ALBUM, options.optString(EXTRA_ALBUM)) + putExtra(EXTRA_IMAGE_URL, options.optString(EXTRA_IMAGE_URL)) + putExtra(EXTRA_POSITION, options.optLong(EXTRA_POSITION, PlaybackState.PLAYBACK_POSITION_UNKNOWN)) + putExtra(EXTRA_DURATION, options.optLong(EXTRA_DURATION)) + putExtra(EXTRA_CAN_SEEK, options.optBoolean(EXTRA_CAN_SEEK)) + putExtra(EXTRA_IS_LOCAL_PLAYER, options.optBoolean(EXTRA_IS_LOCAL_PLAYER, true)) + putExtra(EXTRA_IS_PAUSED, options.optBoolean(EXTRA_IS_PAUSED, true)) + } + + ContextCompat.startForegroundService(context, intent) + + // We may need to request bluetooth permission to react to bluetooth disconnect events + activityEventHandler.emit(ActivityEvent.RequestBluetoothPermission) + return true + } + + @JavascriptInterface + fun hideMediaSession(): Boolean { + val intent = Intent(context, RemotePlayerService::class.java).apply { + action = Constants.ACTION_REPORT + putExtra(EXTRA_PLAYER_ACTION, "playbackstop") + } + context.startService(intent) + return true + } + + @JavascriptInterface + fun updateVolumeLevel(value: Int) { + remoteVolumeProvider.currentVolume = value + } + + @JavascriptInterface + fun downloadFiles(args: String): Boolean { + try { + val files = JSONArray(args) + + repeat(files.length()) { index -> + val file = files.getJSONObject(index) + + val title: String = file.getString("title") + val filename: String = file.getString("filename") + val url: String = file.getString("url") + + emitEvent(ActivityEvent.DownloadFile(Uri.parse(url), title, filename)) + } + } catch (e: JSONException) { + Timber.e("Download failed: %s", e.message) + return false + } + + return true + } + + @JavascriptInterface + fun openClientSettings() { + emitEvent(ActivityEvent.OpenSettings) + } + + @JavascriptInterface + fun openServerSelection() { + emitEvent(ActivityEvent.SelectServer) + } + + @JavascriptInterface + fun exitApp() { + emitEvent(ActivityEvent.ExitApp) + } + + @JavascriptInterface + fun execCast(action: String, args: String) { + emitEvent(ActivityEvent.CastMessage(action, JSONArray(args))) + } + + @Suppress("NOTHING_TO_INLINE") + private inline fun emitEvent(event: ActivityEvent) { + activityEventHandler.emit(event) + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/bridge/NativePlayer.kt b/app/src/main/java/org/jellyfin/mobile/bridge/NativePlayer.kt new file mode 100644 index 0000000..becf4f9 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/bridge/NativePlayer.kt @@ -0,0 +1,65 @@ +package org.jellyfin.mobile.bridge + +import android.webkit.JavascriptInterface +import kotlinx.coroutines.channels.Channel +import org.jellyfin.mobile.app.AppPreferences +import org.jellyfin.mobile.events.ActivityEvent +import org.jellyfin.mobile.events.ActivityEventHandler +import org.jellyfin.mobile.player.interaction.PlayOptions +import org.jellyfin.mobile.player.interaction.PlayerEvent +import org.jellyfin.mobile.settings.VideoPlayerType +import org.jellyfin.mobile.utils.Constants +import org.json.JSONObject + +@Suppress("unused") +class NativePlayer( + private val appPreferences: AppPreferences, + private val activityEventHandler: ActivityEventHandler, + private val playerEventChannel: Channel, +) { + + @JavascriptInterface + fun isEnabled() = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER + + @JavascriptInterface + fun loadPlayer(args: String) { + PlayOptions.fromJson(JSONObject(args))?.let { options -> + activityEventHandler.emit(ActivityEvent.LaunchNativePlayer(options)) + } + } + + @JavascriptInterface + fun pausePlayer() { + playerEventChannel.trySend(PlayerEvent.Pause) + } + + @JavascriptInterface + fun resumePlayer() { + playerEventChannel.trySend(PlayerEvent.Resume) + } + + @JavascriptInterface + fun stopPlayer() { + playerEventChannel.trySend(PlayerEvent.Stop) + } + + @JavascriptInterface + fun destroyPlayer() { + playerEventChannel.trySend(PlayerEvent.Destroy) + } + + @JavascriptInterface + fun seek(ticks: Long) { + playerEventChannel.trySend(PlayerEvent.Seek(ticks / Constants.TICKS_PER_MILLISECOND)) + } + + @JavascriptInterface + fun seekMs(ms: Long) { + playerEventChannel.trySend(PlayerEvent.Seek(ms)) + } + + @JavascriptInterface + fun setVolume(volume: Int) { + playerEventChannel.trySend(PlayerEvent.SetVolume(volume)) + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/data/DatabaseModule.kt b/app/src/main/java/org/jellyfin/mobile/data/DatabaseModule.kt new file mode 100644 index 0000000..0b44e3c --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/data/DatabaseModule.kt @@ -0,0 +1,17 @@ +package org.jellyfin.mobile.data + +import androidx.room.Room +import org.koin.android.ext.koin.androidApplication +import org.koin.dsl.module + +val databaseModule = module { + single { + Room.databaseBuilder(androidApplication(), JellyfinDatabase::class.java, "jellyfin") + .addMigrations() + .fallbackToDestructiveMigrationFrom(1) + .fallbackToDestructiveMigrationOnDowngrade() + .build() + } + single { get().serverDao } + single { get().userDao } +} diff --git a/app/src/main/java/org/jellyfin/mobile/data/JellyfinDatabase.kt b/app/src/main/java/org/jellyfin/mobile/data/JellyfinDatabase.kt new file mode 100644 index 0000000..660f777 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/data/JellyfinDatabase.kt @@ -0,0 +1,14 @@ +package org.jellyfin.mobile.data + +import androidx.room.Database +import androidx.room.RoomDatabase +import org.jellyfin.mobile.data.dao.ServerDao +import org.jellyfin.mobile.data.dao.UserDao +import org.jellyfin.mobile.data.entity.ServerEntity +import org.jellyfin.mobile.data.entity.UserEntity + +@Database(entities = [ServerEntity::class, UserEntity::class], version = 2) +abstract class JellyfinDatabase : RoomDatabase() { + abstract val serverDao: ServerDao + abstract val userDao: UserDao +} diff --git a/app/src/main/java/org/jellyfin/mobile/data/dao/ServerDao.kt b/app/src/main/java/org/jellyfin/mobile/data/dao/ServerDao.kt new file mode 100644 index 0000000..e5db6df --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/data/dao/ServerDao.kt @@ -0,0 +1,25 @@ +package org.jellyfin.mobile.data.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import org.jellyfin.mobile.data.entity.ServerEntity +import org.jellyfin.mobile.data.entity.ServerEntity.Key.TABLE_NAME + +@Dao +interface ServerDao { + @Insert(onConflict = OnConflictStrategy.ABORT) + fun insert(entity: ServerEntity): Long + + fun insert(hostname: String) = insert(ServerEntity(hostname)) + + @Query("SELECT * FROM $TABLE_NAME WHERE id = :id") + fun getServer(id: Long): ServerEntity? + + @Query("SELECT * FROM $TABLE_NAME ORDER BY last_used_timestamp DESC") + fun getAllServers(): List + + @Query("SELECT * FROM $TABLE_NAME WHERE hostname = :hostname") + fun getServerByHostname(hostname: String): ServerEntity? +} diff --git a/app/src/main/java/org/jellyfin/mobile/data/dao/UserDao.kt b/app/src/main/java/org/jellyfin/mobile/data/dao/UserDao.kt new file mode 100644 index 0000000..7f072c7 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/data/dao/UserDao.kt @@ -0,0 +1,54 @@ +package org.jellyfin.mobile.data.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import org.jellyfin.mobile.data.entity.ServerUser +import org.jellyfin.mobile.data.entity.UserEntity +import org.jellyfin.mobile.data.entity.UserEntity.Key.ACCESS_TOKEN +import org.jellyfin.mobile.data.entity.UserEntity.Key.ID +import org.jellyfin.mobile.data.entity.UserEntity.Key.SERVER_ID +import org.jellyfin.mobile.data.entity.UserEntity.Key.TABLE_NAME +import org.jellyfin.mobile.data.entity.UserEntity.Key.USER_ID + +@Dao +@Suppress("TooManyFunctions") +interface UserDao { + @Insert(onConflict = OnConflictStrategy.ABORT) + fun insert(entity: UserEntity): Long + + fun insert(serverId: Long, userId: String, accessToken: String?) = insert(UserEntity(serverId, userId, accessToken)) + + @Transaction + fun upsert(serverId: Long, userId: String, accessToken: String?): Long { + return when (val user = getByUserId(serverId, userId)) { + null -> insert(serverId, userId, accessToken) + else -> { + update(user.id, accessToken) + user.id + } + } + } + + @Transaction + @Query("SELECT * FROM $TABLE_NAME WHERE $SERVER_ID = :serverId AND $ID = :userId") + fun getServerUser(serverId: Long, userId: Long): ServerUser? + + @Transaction + @Query("SELECT * FROM $TABLE_NAME WHERE $SERVER_ID = :serverId AND $USER_ID = :userId") + fun getServerUser(serverId: Long, userId: String): ServerUser? + + @Query("SELECT * FROM $TABLE_NAME WHERE $SERVER_ID = :serverId AND $USER_ID = :userId") + fun getByUserId(serverId: Long, userId: String): UserEntity? + + @Query("SELECT * FROM $TABLE_NAME WHERE $SERVER_ID = :serverId") + fun getAllForServer(serverId: Long): List + + @Query("UPDATE $TABLE_NAME SET access_token = :accessToken WHERE $ID = :userId") + fun update(userId: Long, accessToken: String?): Int + + @Query("UPDATE $TABLE_NAME SET $ACCESS_TOKEN = NULL WHERE $ID = :userId") + fun logout(userId: Long) +} diff --git a/app/src/main/java/org/jellyfin/mobile/data/entity/ServerEntity.kt b/app/src/main/java/org/jellyfin/mobile/data/entity/ServerEntity.kt new file mode 100644 index 0000000..7850484 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/data/entity/ServerEntity.kt @@ -0,0 +1,31 @@ +package org.jellyfin.mobile.data.entity + +import android.os.Parcelable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import kotlinx.parcelize.Parcelize +import org.jellyfin.mobile.data.entity.ServerEntity.Key.HOSTNAME +import org.jellyfin.mobile.data.entity.ServerEntity.Key.TABLE_NAME + +@Parcelize +@Entity(tableName = TABLE_NAME, indices = [Index(value = arrayOf(HOSTNAME), unique = true)]) +data class ServerEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = ID) + val id: Long, + @ColumnInfo(name = HOSTNAME) + val hostname: String, + @ColumnInfo(name = LAST_USED_TIMESTAMP) + val lastUsedTimestamp: Long, +) : Parcelable { + constructor(hostname: String) : this(0, hostname, System.currentTimeMillis()) + + companion object Key { + const val TABLE_NAME = "Server" + const val ID = "id" + const val HOSTNAME = "hostname" + const val LAST_USED_TIMESTAMP = "last_used_timestamp" + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/data/entity/ServerUser.kt b/app/src/main/java/org/jellyfin/mobile/data/entity/ServerUser.kt new file mode 100644 index 0000000..62979f7 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/data/entity/ServerUser.kt @@ -0,0 +1,13 @@ +package org.jellyfin.mobile.data.entity + +import androidx.room.Embedded +import androidx.room.Relation + +data class ServerUser( + @Embedded val user: UserEntity, + @Relation( + parentColumn = UserEntity.SERVER_ID, + entityColumn = ServerEntity.ID, + ) + val server: ServerEntity, +) diff --git a/app/src/main/java/org/jellyfin/mobile/data/entity/UserEntity.kt b/app/src/main/java/org/jellyfin/mobile/data/entity/UserEntity.kt new file mode 100644 index 0000000..5487c67 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/data/entity/UserEntity.kt @@ -0,0 +1,45 @@ +package org.jellyfin.mobile.data.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import org.jellyfin.mobile.data.entity.UserEntity.Key.SERVER_ID +import org.jellyfin.mobile.data.entity.UserEntity.Key.TABLE_NAME +import org.jellyfin.mobile.data.entity.UserEntity.Key.USER_ID + +@Entity( + tableName = TABLE_NAME, + indices = [ + Index(value = [SERVER_ID, USER_ID], unique = true), + ], + foreignKeys = [ + ForeignKey(entity = ServerEntity::class, parentColumns = [ServerEntity.ID], childColumns = [SERVER_ID]), + ], +) +data class UserEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = ID) + val id: Long, + @ColumnInfo(name = SERVER_ID) + val serverId: Long, + @ColumnInfo(name = USER_ID) + val userId: String, + @ColumnInfo(name = ACCESS_TOKEN) + val accessToken: String?, + @ColumnInfo(name = LAST_LOGIN_TIMESTAMP) + val lastLoginTimestamp: Long, +) { + constructor(serverId: Long, userId: String, accessToken: String?) : + this(0, serverId, userId, accessToken, System.currentTimeMillis()) + + companion object Key { + const val TABLE_NAME = "User" + const val ID = "id" + const val SERVER_ID = "server_id" + const val USER_ID = "user_id" + const val ACCESS_TOKEN = "access_token" + const val LAST_LOGIN_TIMESTAMP = "last_login_timestamp" + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt b/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt new file mode 100644 index 0000000..e5a9ec5 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt @@ -0,0 +1,17 @@ +package org.jellyfin.mobile.events + +import android.net.Uri +import org.jellyfin.mobile.player.interaction.PlayOptions +import org.json.JSONArray + +sealed class ActivityEvent { + class ChangeFullscreen(val isFullscreen: Boolean) : ActivityEvent() + class LaunchNativePlayer(val playOptions: PlayOptions) : ActivityEvent() + class OpenUrl(val uri: String) : ActivityEvent() + class DownloadFile(val uri: Uri, val title: String, val filename: String) : ActivityEvent() + class CastMessage(val action: String, val args: JSONArray) : ActivityEvent() + data object RequestBluetoothPermission : ActivityEvent() + data object OpenSettings : ActivityEvent() + data object SelectServer : ActivityEvent() + data object ExitApp : ActivityEvent() +} diff --git a/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt b/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt new file mode 100644 index 0000000..349751b --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt @@ -0,0 +1,118 @@ +package org.jellyfin.mobile.events + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.content.pm.ActivityInfo +import android.net.Uri +import android.os.Bundle +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import org.jellyfin.mobile.MainActivity +import org.jellyfin.mobile.R +import org.jellyfin.mobile.bridge.JavascriptCallback +import org.jellyfin.mobile.player.ui.PlayerFragment +import org.jellyfin.mobile.player.ui.PlayerFullscreenHelper +import org.jellyfin.mobile.settings.SettingsFragment +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.utils.extensions.addFragment +import org.jellyfin.mobile.utils.requestDownload +import org.jellyfin.mobile.webapp.WebappFunctionChannel +import timber.log.Timber + +class ActivityEventHandler( + private val webappFunctionChannel: WebappFunctionChannel, +) { + private val eventsFlow = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + fun MainActivity.subscribe() { + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) { + eventsFlow.collect { event -> + handleEvent(event) + } + } + } + } + + @Suppress("CyclomaticComplexMethod", "LongMethod") + private fun MainActivity.handleEvent(event: ActivityEvent) { + when (event) { + is ActivityEvent.ChangeFullscreen -> { + val fullscreenHelper = PlayerFullscreenHelper(window) + if (event.isFullscreen) { + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + fullscreenHelper.enableFullscreen() + window.setBackgroundDrawable(null) + } else { + // Reset screen orientation + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + fullscreenHelper.disableFullscreen() + // Reset window background color + window.setBackgroundDrawableResource(R.color.theme_background) + } + } + is ActivityEvent.LaunchNativePlayer -> { + val args = Bundle().apply { + putParcelable(Constants.EXTRA_MEDIA_PLAY_OPTIONS, event.playOptions) + } + supportFragmentManager.addFragment(args) + } + is ActivityEvent.OpenUrl -> { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(event.uri)) + startActivity(intent) + } catch (e: ActivityNotFoundException) { + Timber.e("openIntent: %s", e.message) + } + } + is ActivityEvent.DownloadFile -> { + lifecycleScope.launch { + with(event) { requestDownload(uri, title, filename) } + } + } + is ActivityEvent.CastMessage -> { + val action = event.action + chromecast.execute( + action, + event.args, + object : JavascriptCallback() { + override fun callback(keep: Boolean, err: String?, result: String?) { + webappFunctionChannel.call( + """window.NativeShell.castCallback("$action", $keep, $err, $result);""", + ) + } + }, + ) + } + ActivityEvent.RequestBluetoothPermission -> { + lifecycleScope.launch { + bluetoothPermissionHelper.requestBluetoothPermissionIfNecessary() + } + } + ActivityEvent.OpenSettings -> { + supportFragmentManager.addFragment() + } + ActivityEvent.SelectServer -> { + mainViewModel.resetServer() + } + ActivityEvent.ExitApp -> { + if (serviceBinder?.isPlaying == true) { + moveTaskToBack(false) + } else { + finish() + } + } + } + } + + fun emit(event: ActivityEvent) { + eventsFlow.tryEmit(event) + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerException.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerException.kt new file mode 100644 index 0000000..1264808 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerException.kt @@ -0,0 +1,7 @@ +package org.jellyfin.mobile.player + +sealed class PlayerException(cause: Throwable?) : Exception(cause) { + class InvalidPlayOptions(cause: Throwable? = null) : PlayerException(cause) + class NetworkFailure(cause: Throwable? = null) : PlayerException(cause) + class UnsupportedContent(cause: Throwable? = null) : PlayerException(cause) +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt new file mode 100644 index 0000000..526feb0 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt @@ -0,0 +1,551 @@ +package org.jellyfin.mobile.player + +import android.annotation.SuppressLint +import android.app.Application +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.session.MediaSession +import android.media.session.PlaybackState +import androidx.core.content.getSystemService +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.lifecycle.viewModelScope +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.DefaultRenderersFactory +import com.google.android.exoplayer2.ExoPlayer +import com.google.android.exoplayer2.PlaybackException +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.analytics.DefaultAnalyticsCollector +import com.google.android.exoplayer2.mediacodec.MediaCodecDecoderException +import com.google.android.exoplayer2.mediacodec.MediaCodecInfo +import com.google.android.exoplayer2.mediacodec.MediaCodecSelector +import com.google.android.exoplayer2.source.MediaSource +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector +import com.google.android.exoplayer2.util.Clock +import com.google.android.exoplayer2.util.EventLogger +import com.google.android.exoplayer2.util.MimeTypes +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jellyfin.mobile.BuildConfig +import org.jellyfin.mobile.app.PLAYER_EVENT_CHANNEL +import org.jellyfin.mobile.player.interaction.PlayerEvent +import org.jellyfin.mobile.player.interaction.PlayerLifecycleObserver +import org.jellyfin.mobile.player.interaction.PlayerMediaSessionCallback +import org.jellyfin.mobile.player.interaction.PlayerNotificationHelper +import org.jellyfin.mobile.player.queue.QueueManager +import org.jellyfin.mobile.player.source.JellyfinMediaSource +import org.jellyfin.mobile.player.ui.DecoderType +import org.jellyfin.mobile.player.ui.DisplayPreferences +import org.jellyfin.mobile.player.ui.PlayState +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.utils.Constants.SUPPORTED_VIDEO_PLAYER_PLAYBACK_ACTIONS +import org.jellyfin.mobile.utils.applyDefaultAudioAttributes +import org.jellyfin.mobile.utils.applyDefaultLocalAudioAttributes +import org.jellyfin.mobile.utils.extensions.scaleInRange +import org.jellyfin.mobile.utils.extensions.width +import org.jellyfin.mobile.utils.getVolumeLevelPercent +import org.jellyfin.mobile.utils.getVolumeRange +import org.jellyfin.mobile.utils.logTracks +import org.jellyfin.mobile.utils.seekToOffset +import org.jellyfin.mobile.utils.setPlaybackState +import org.jellyfin.mobile.utils.toMediaMetadata +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.ApiClientException +import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi +import org.jellyfin.sdk.api.client.extensions.hlsSegmentApi +import org.jellyfin.sdk.api.client.extensions.playStateApi +import org.jellyfin.sdk.api.operations.DisplayPreferencesApi +import org.jellyfin.sdk.api.operations.HlsSegmentApi +import org.jellyfin.sdk.api.operations.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 +import org.jellyfin.sdk.model.api.PlaybackStopInfo +import org.jellyfin.sdk.model.api.RepeatMode +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.component.inject +import org.koin.core.qualifier.named +import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean + +@Suppress("TooManyFunctions") +class PlayerViewModel(application: Application) : AndroidViewModel(application), KoinComponent, Player.Listener { + private val apiClient: ApiClient = get() + private val displayPreferencesApi: DisplayPreferencesApi = apiClient.displayPreferencesApi + private val playStateApi: PlayStateApi = apiClient.playStateApi + private val hlsSegmentApi: HlsSegmentApi = apiClient.hlsSegmentApi + + private val lifecycleObserver = PlayerLifecycleObserver(this) + private val audioManager: AudioManager by lazy { getApplication().getSystemService()!! } + val notificationHelper: PlayerNotificationHelper by lazy { PlayerNotificationHelper(this) } + + // Media source handling + private val trackSelector = DefaultTrackSelector(getApplication()) + val trackSelectionHelper = TrackSelectionHelper(this, trackSelector) + val queueManager = QueueManager(this) + val mediaSourceOrNull: JellyfinMediaSource? + get() = queueManager.currentMediaSourceOrNull + + // ExoPlayer + private val _player = MutableLiveData() + private val _playerState = MutableLiveData() + private val _decoderType = MutableLiveData() + val player: LiveData get() = _player + val playerState: LiveData get() = _playerState + val decoderType: LiveData get() = _decoderType + + private val _error = MutableLiveData() + val error: LiveData = _error + + private val eventLogger = EventLogger() + private var analyticsCollector = buildAnalyticsCollector() + private val initialTracksSelected = AtomicBoolean(false) + private var fallbackPreferExtensionRenderers = false + + private var progressUpdateJob: Job? = null + + /** + * Returns the current ExoPlayer instance or null + */ + val playerOrNull: ExoPlayer? get() = _player.value + + private val playerEventChannel: Channel by inject(named(PLAYER_EVENT_CHANNEL)) + + val mediaSession: MediaSession by lazy { + MediaSession( + getApplication().applicationContext, + javaClass.simpleName.removePrefix(BuildConfig.APPLICATION_ID), + ).apply { + @Suppress("DEPRECATION") + setFlags(MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS or MediaSession.FLAG_HANDLES_MEDIA_BUTTONS) + setCallback(mediaSessionCallback) + applyDefaultLocalAudioAttributes(AudioAttributes.CONTENT_TYPE_MOVIE) + } + } + private val mediaSessionCallback = PlayerMediaSessionCallback(this) + + private var displayPreferences = DisplayPreferences() + + init { + ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver) + + // Load display preferences + viewModelScope.launch { + try { + val displayPreferencesDto by displayPreferencesApi.getDisplayPreferences( + displayPreferencesId = Constants.DISPLAY_PREFERENCES_ID_USER_SETTINGS, + client = Constants.DISPLAY_PREFERENCES_CLIENT_EMBY, + ) + + val customPrefs = displayPreferencesDto.customPrefs + + displayPreferences = DisplayPreferences( + skipBackLength = customPrefs[Constants.DISPLAY_PREFERENCES_SKIP_BACK_LENGTH]?.toLongOrNull() + ?: Constants.DEFAULT_SEEK_TIME_MS, + skipForwardLength = customPrefs[Constants.DISPLAY_PREFERENCES_SKIP_FORWARD_LENGTH]?.toLongOrNull() + ?: Constants.DEFAULT_SEEK_TIME_MS, + ) + } catch (e: ApiClientException) { + Timber.e(e, "Failed to load display preferences") + } + } + + // Subscribe to player events from webapp + viewModelScope.launch { + for (event in playerEventChannel) { + when (event) { + PlayerEvent.Pause -> mediaSessionCallback.onPause() + PlayerEvent.Resume -> mediaSessionCallback.onPlay() + PlayerEvent.Stop, PlayerEvent.Destroy -> mediaSessionCallback.onStop() + is PlayerEvent.Seek -> playerOrNull?.seekTo(event.ms) + is PlayerEvent.SetVolume -> { + setVolume(event.volume) + playerOrNull?.reportPlaybackState() + } + } + } + } + } + + private fun buildAnalyticsCollector() = DefaultAnalyticsCollector(Clock.DEFAULT).apply { + addListener(eventLogger) + } + + /** + * Setup a new [ExoPlayer] for video playback, register callbacks and set attributes + */ + fun setupPlayer() { + val renderersFactory = DefaultRenderersFactory(getApplication()).apply { + setEnableDecoderFallback(true) // Fallback only works if initialization fails, not decoding at playback time + val rendererMode = when { + fallbackPreferExtensionRenderers -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER + else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON + } + setExtensionRendererMode(rendererMode) + setMediaCodecSelector { mimeType, requiresSecureDecoder, requiresTunnelingDecoder -> + val decoderInfoList = MediaCodecSelector.DEFAULT.getDecoderInfos( + mimeType, + requiresSecureDecoder, + requiresTunnelingDecoder, + ) + // Allow decoder selection only for video track + if (!MimeTypes.isVideo(mimeType)) { + return@setMediaCodecSelector decoderInfoList + } + val filteredDecoderList = when (decoderType.value) { + DecoderType.HARDWARE -> decoderInfoList.filter(MediaCodecInfo::hardwareAccelerated) + DecoderType.SOFTWARE -> decoderInfoList.filterNot(MediaCodecInfo::hardwareAccelerated) + else -> decoderInfoList + } + // Update the decoderType based on the first decoder selected + filteredDecoderList.firstOrNull()?.let { decoder -> + val decoderType = when { + decoder.hardwareAccelerated -> DecoderType.HARDWARE + else -> DecoderType.SOFTWARE + } + _decoderType.postValue(decoderType) + } + + filteredDecoderList + } + } + _player.value = ExoPlayer.Builder(getApplication(), renderersFactory, get()).apply { + setUsePlatformDiagnostics(false) + setTrackSelector(trackSelector) + setAnalyticsCollector(analyticsCollector) + }.build().apply { + addListener(this@PlayerViewModel) + applyDefaultAudioAttributes(C.AUDIO_CONTENT_TYPE_MOVIE) + } + } + + /** + * Release the current ExoPlayer and stop/release the current MediaSession + */ + private fun releasePlayer() { + notificationHelper.dismissNotification() + mediaSession.isActive = false + mediaSession.release() + playerOrNull?.run { + removeListener(this@PlayerViewModel) + release() + } + _player.value = null + } + + fun load(jellyfinMediaSource: JellyfinMediaSource, exoMediaSource: MediaSource, playWhenReady: Boolean) { + val player = playerOrNull ?: return + + player.setMediaSource(exoMediaSource) + player.prepare() + + initialTracksSelected.set(false) + + val startTime = jellyfinMediaSource.startTimeMs + if (startTime > 0) player.seekTo(startTime) + player.playWhenReady = playWhenReady + + mediaSession.setMetadata(jellyfinMediaSource.toMediaMetadata()) + + viewModelScope.launch { + player.reportPlaybackStart(jellyfinMediaSource) + } + } + + private fun startProgressUpdates() { + progressUpdateJob = viewModelScope.launch { + while (true) { + delay(Constants.PLAYER_TIME_UPDATE_RATE) + playerOrNull?.reportPlaybackState() + } + } + } + + private fun stopProgressUpdates() { + progressUpdateJob?.cancel() + } + + /** + * Updates the decoder of the [Player]. This will destroy the current player and + * recreate the player with the selected decoder type + */ + fun updateDecoderType(type: DecoderType) { + _decoderType.postValue(type) + analyticsCollector.release() + val playedTime = playerOrNull?.currentPosition ?: 0L + // Stop and release the player without ending playback + playerOrNull?.run { + removeListener(this@PlayerViewModel) + release() + } + analyticsCollector = buildAnalyticsCollector() + setupPlayer() + queueManager.currentMediaSourceOrNull?.startTimeMs = playedTime + queueManager.tryRestartPlayback() + } + + private suspend fun Player.reportPlaybackStart(mediaSource: JellyfinMediaSource) { + try { + playStateApi.reportPlaybackStart( + PlaybackStartInfo( + itemId = mediaSource.itemId, + playMethod = mediaSource.playMethod, + playSessionId = mediaSource.playSessionId, + audioStreamIndex = mediaSource.selectedAudioStream?.index, + subtitleStreamIndex = mediaSource.selectedSubtitleStream?.index, + isPaused = !isPlaying, + isMuted = false, + canSeek = true, + positionTicks = mediaSource.startTimeMs * Constants.TICKS_PER_MILLISECOND, + volumeLevel = audioManager.getVolumeLevelPercent(), + repeatMode = RepeatMode.REPEAT_NONE, + playbackOrder = PlaybackOrder.DEFAULT, + ), + ) + } catch (e: ApiClientException) { + Timber.e(e, "Failed to report playback start") + } + } + + private suspend fun Player.reportPlaybackState() { + val mediaSource = mediaSourceOrNull ?: return + val playbackPositionMillis = currentPosition + if (playbackState != Player.STATE_ENDED) { + val stream = AudioManager.STREAM_MUSIC + val volumeRange = audioManager.getVolumeRange(stream) + val currentVolume = audioManager.getStreamVolume(stream) + try { + playStateApi.reportPlaybackProgress( + PlaybackProgressInfo( + itemId = mediaSource.itemId, + playMethod = mediaSource.playMethod, + playSessionId = mediaSource.playSessionId, + audioStreamIndex = mediaSource.selectedAudioStream?.index, + subtitleStreamIndex = mediaSource.selectedSubtitleStream?.index, + isPaused = !isPlaying, + isMuted = false, + canSeek = true, + positionTicks = playbackPositionMillis * Constants.TICKS_PER_MILLISECOND, + volumeLevel = (currentVolume - volumeRange.first) * Constants.PERCENT_MAX / volumeRange.width, + repeatMode = RepeatMode.REPEAT_NONE, + playbackOrder = PlaybackOrder.DEFAULT, + ), + ) + } catch (e: ApiClientException) { + Timber.e(e, "Failed to report playback progress") + } + } + } + + private fun reportPlaybackStop() { + val mediaSource = mediaSourceOrNull ?: return + val player = playerOrNull ?: return + val hasFinished = player.playbackState == Player.STATE_ENDED + val lastPositionTicks = when { + hasFinished -> mediaSource.runTimeTicks + else -> player.currentPosition * Constants.TICKS_PER_MILLISECOND + } + + // viewModelScope may already be cancelled at this point, so we need to fallback + CoroutineScope(Dispatchers.Main).launch { + try { + // Report stopped playback + playStateApi.reportPlaybackStopped( + PlaybackStopInfo( + itemId = mediaSource.itemId, + positionTicks = lastPositionTicks, + playSessionId = mediaSource.playSessionId, + liveStreamId = mediaSource.liveStreamId, + failed = false, + ), + ) + + // Mark video as watched if playback finished + if (hasFinished) { + playStateApi.markPlayedItem(itemId = mediaSource.itemId) + } + + // Stop active encoding if transcoding + stopTranscoding(mediaSource) + } catch (e: ApiClientException) { + Timber.e(e, "Failed to report playback stop") + } + } + } + + suspend fun stopTranscoding(mediaSource: JellyfinMediaSource) { + if (mediaSource.playMethod == PlayMethod.TRANSCODE) { + hlsSegmentApi.stopEncodingProcess( + deviceId = apiClient.deviceInfo.id, + playSessionId = mediaSource.playSessionId, + ) + } + } + + // Player controls + + fun play() { + playerOrNull?.play() + } + + fun pause() { + playerOrNull?.pause() + } + + fun rewind() { + playerOrNull?.seekToOffset(displayPreferences.skipBackLength.unaryMinus()) + } + + fun fastForward() { + playerOrNull?.seekToOffset(displayPreferences.skipForwardLength) + } + + fun skipToPrevious() { + val player = playerOrNull ?: return + when { + // Skip to previous element + player.currentPosition <= Constants.MAX_SKIP_TO_PREV_MS -> viewModelScope.launch { + pause() + if (!queueManager.previous()) { + // Skip to previous failed, go to start of video anyway + playerOrNull?.seekTo(0) + play() + } + } + // Rewind to start of track if not at the start already + else -> player.seekTo(0) + } + } + + fun skipToNext() { + viewModelScope.launch { + queueManager.next() + } + } + + fun getStateAndPause(): PlayState? { + val player = playerOrNull ?: return null + + val playWhenReady = player.playWhenReady + player.pause() + val position = player.contentPosition + + return PlayState(playWhenReady, position) + } + + fun logTracks() { + playerOrNull?.logTracks(analyticsCollector) + } + + suspend fun changeBitrate(bitrate: Int?): Boolean { + return queueManager.changeBitrate(bitrate) + } + + /** + * Set the playback speed to [speed] + * + * @return true if the speed was changed + */ + fun setPlaybackSpeed(speed: Float): Boolean { + val player = playerOrNull ?: return false + + val parameters = player.playbackParameters + if (parameters.speed != speed) { + player.playbackParameters = parameters.withSpeed(speed) + return true + } + return false + } + + fun stop() { + pause() + reportPlaybackStop() + releasePlayer() + } + + private fun setVolume(percent: Int) { + if (audioManager.isVolumeFixed) return + val stream = AudioManager.STREAM_MUSIC + val volumeRange = audioManager.getVolumeRange(stream) + val scaled = volumeRange.scaleInRange(percent) + audioManager.setStreamVolume(stream, scaled, 0) + } + + @SuppressLint("SwitchIntDef") + override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { + val player = playerOrNull ?: return + + // Notify fragment of current state + _playerState.value = playbackState + + // Initialise various components + if (playbackState == Player.STATE_READY) { + if (!initialTracksSelected.getAndSet(true)) { + trackSelectionHelper.selectInitialTracks() + } + mediaSession.isActive = true + notificationHelper.postNotification() + } + + // Setup or stop regular progress updates + if (playbackState == Player.STATE_READY && playWhenReady) { + startProgressUpdates() + } else { + stopProgressUpdates() + } + + // Update media session + var playbackActions = SUPPORTED_VIDEO_PLAYER_PLAYBACK_ACTIONS + if (queueManager.hasPrevious()) { + playbackActions = playbackActions or PlaybackState.ACTION_SKIP_TO_PREVIOUS + } + if (queueManager.hasNext()) { + playbackActions = playbackActions or PlaybackState.ACTION_SKIP_TO_NEXT + } + mediaSession.setPlaybackState(player, playbackActions) + + // Force update playback state and position + viewModelScope.launch { + when (playbackState) { + Player.STATE_READY, Player.STATE_BUFFERING -> { + player.reportPlaybackState() + } + Player.STATE_ENDED -> { + reportPlaybackStop() + if (!queueManager.next()) { + releasePlayer() + } + } + } + } + } + + override fun onPlayerError(error: PlaybackException) { + if (error.cause is MediaCodecDecoderException && !fallbackPreferExtensionRenderers) { + Timber.e(error.cause, "Decoder failed, attempting to restart playback with decoder extensions preferred") + playerOrNull?.run { + removeListener(this@PlayerViewModel) + release() + } + fallbackPreferExtensionRenderers = true + setupPlayer() + queueManager.tryRestartPlayback() + } else { + _error.postValue(error.localizedMessage.orEmpty()) + } + } + + override fun onCleared() { + reportPlaybackStop() + ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver) + releasePlayer() + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/TrackSelectionHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/TrackSelectionHelper.kt new file mode 100644 index 0000000..cd4d2c9 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/TrackSelectionHelper.kt @@ -0,0 +1,173 @@ +package org.jellyfin.mobile.player + +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector +import org.jellyfin.mobile.player.source.ExternalSubtitleStream +import org.jellyfin.mobile.player.source.JellyfinMediaSource +import org.jellyfin.mobile.utils.clearSelectionAndDisableRendererByType +import org.jellyfin.mobile.utils.selectTrackByTypeAndGroup +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.PlayMethod +import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod + +class TrackSelectionHelper( + private val viewModel: PlayerViewModel, + private val trackSelector: DefaultTrackSelector, +) { + private val mediaSourceOrNull: JellyfinMediaSource? + get() = viewModel.mediaSourceOrNull + + fun selectInitialTracks() { + val mediaSource = mediaSourceOrNull ?: return + + mediaSource.selectedAudioStream?.let { stream -> + selectPlayerAudioTrack(mediaSource, stream, initial = true) + } + selectSubtitleTrack(mediaSource, mediaSource.selectedSubtitleStream, initial = true) + } + + /** + * Select an audio track in the media source and apply changes to the current player, if necessary and possible. + * + * @param mediaStreamIndex the [MediaStream.index] that should be selected + * @return true if the audio track was changed + */ + suspend fun selectAudioTrack(mediaStreamIndex: Int): Boolean { + val mediaSource = mediaSourceOrNull ?: return false + val selectedMediaStream = mediaSource.mediaStreams[mediaStreamIndex] + require(selectedMediaStream.type == MediaStreamType.AUDIO) + + // For transcoding and external streams, we need to restart playback + if (mediaSource.playMethod == PlayMethod.TRANSCODE || selectedMediaStream.isExternal) { + return viewModel.queueManager.selectAudioStreamAndRestartPlayback(selectedMediaStream) + } + + return selectPlayerAudioTrack(mediaSource, selectedMediaStream, initial = false).also { success -> + if (success) viewModel.logTracks() + } + } + + /** + * Select the audio track in the player. + * + * @param initial whether this is an initial selection and checks for re-selection should be skipped. + * @see selectPlayerAudioTrack + */ + @Suppress("ReturnCount") + private fun selectPlayerAudioTrack(mediaSource: JellyfinMediaSource, audioStream: MediaStream, initial: Boolean): Boolean { + if (mediaSource.playMethod == PlayMethod.TRANSCODE) { + // Transcoding does not require explicit audio selection + return true + } + + when { + // Fast-pass: Skip execution on subsequent calls with the correct selection or if only one track exists + mediaSource.audioStreams.size == 1 || !initial && audioStream === mediaSource.selectedAudioStream -> return true + // Apply selection in media source, abort on failure + !mediaSource.selectAudioStream(audioStream) -> return false + } + + val player = viewModel.playerOrNull ?: return false + val embeddedStreamIndex = mediaSource.getEmbeddedStreamIndex(audioStream) + val sortedTrackGroups = player.currentTracks.groups.sortedBy { group -> + val formatId = group.mediaTrackGroup.getFormat(0).id + + // Sort by format ID, but pad number string with zeroes to ensure proper sorting + formatId?.toIntOrNull()?.let { id -> "%05d".format(id) } ?: formatId + } + val audioGroup = sortedTrackGroups.getOrNull(embeddedStreamIndex) ?: return false + + return trackSelector.selectTrackByTypeAndGroup(C.TRACK_TYPE_AUDIO, audioGroup.mediaTrackGroup) + } + + /** + * Select a subtitle track in the media source and apply changes to the current player, if necessary. + * + * @param mediaStreamIndex the [MediaStream.index] that should be selected, or -1 to disable subtitles + * @return true if the subtitle was changed + */ + suspend fun selectSubtitleTrack(mediaStreamIndex: Int): Boolean { + val mediaSource = viewModel.mediaSourceOrNull ?: return false + val selectedMediaStream = mediaSource.mediaStreams.getOrNull(mediaStreamIndex) + require(selectedMediaStream == null || selectedMediaStream.type == MediaStreamType.SUBTITLE) + + // If the selected subtitle stream requires encoding or the current subtitle is baked into the stream, + // we need to restart playback + if ( + selectedMediaStream?.deliveryMethod == SubtitleDeliveryMethod.ENCODE || + mediaSource.selectedSubtitleStream?.deliveryMethod == SubtitleDeliveryMethod.ENCODE + ) { + return viewModel.queueManager.selectSubtitleStreamAndRestartPlayback(selectedMediaStream) + } + + return selectSubtitleTrack(mediaSource, selectedMediaStream, initial = false).also { success -> + if (success) viewModel.logTracks() + } + } + + /** + * Select the subtitle track in the player. + * + * @param initial whether this is an initial selection and checks for re-selection should be skipped. + * @see selectSubtitleTrack + */ + @Suppress("ReturnCount") + private fun selectSubtitleTrack(mediaSource: JellyfinMediaSource, subtitleStream: MediaStream?, initial: Boolean): Boolean { + when { + // Fast-pass: Skip execution on subsequent calls with the same selection + !initial && subtitleStream === mediaSource.selectedSubtitleStream -> return true + // Apply selection in media source, abort on failure + !mediaSource.selectSubtitleStream(subtitleStream) -> return false + } + + // Apply selection in player + if (subtitleStream == null) { + // If no subtitle is selected, simply clear the selection and disable the subtitle renderer + trackSelector.clearSelectionAndDisableRendererByType(C.TRACK_TYPE_TEXT) + return true + } + + val player = viewModel.playerOrNull ?: return false + when (subtitleStream.deliveryMethod) { + SubtitleDeliveryMethod.ENCODE -> { + // Normally handled in selectSubtitleTrack(int) by restarting playback, + // initial selection is always considered successful + return true + } + SubtitleDeliveryMethod.EMBED -> { + // For embedded subtitles, we can match by the index of this stream in all embedded streams. + val embeddedStreamIndex = mediaSource.getEmbeddedStreamIndex(subtitleStream) + val subtitleGroup = player.currentTracks.groups.getOrNull(embeddedStreamIndex) ?: return false + + return trackSelector.selectTrackByTypeAndGroup(C.TRACK_TYPE_TEXT, subtitleGroup.mediaTrackGroup) + } + SubtitleDeliveryMethod.EXTERNAL -> { + // For external subtitles, we can simply match the ID that we set when creating the player media source. + for (group in player.currentTracks.groups) { + if (group.getTrackFormat(0).id == "${ExternalSubtitleStream.ID_PREFIX}${subtitleStream.index}") { + return trackSelector.selectTrackByTypeAndGroup(C.TRACK_TYPE_TEXT, group.mediaTrackGroup) + } + } + return false + } + else -> return false + } + } + + /** + * Toggle subtitles, selecting the first by [MediaStream.index] if there are multiple. + * + * @return true if subtitles are enabled now, false if not + */ + suspend fun toggleSubtitles(): Boolean { + val mediaSource = mediaSourceOrNull ?: return false + val newSubtitleIndex = when (mediaSource.selectedSubtitleStream) { + null -> mediaSource.subtitleStreams.firstOrNull()?.index ?: -1 + else -> -1 + } + selectSubtitleTrack(newSubtitleIndex) + // Media source may have changed by now + return mediaSourceOrNull?.selectedSubtitleStream != null + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/audio/AudioNotificationManager.kt b/app/src/main/java/org/jellyfin/mobile/player/audio/AudioNotificationManager.kt new file mode 100644 index 0000000..928755b --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/audio/AudioNotificationManager.kt @@ -0,0 +1,133 @@ +// Taken and adapted from https://github.com/android/uamp/blob/main/common/src/main/java/com/example/android/uamp/media/UampNotificationManager.kt + +/* + * Copyright 2020 Google Inc. All rights reserved. + * + * 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 org.jellyfin.mobile.player.audio + +import android.app.PendingIntent +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.support.v4.media.session.MediaControllerCompat +import android.support.v4.media.session.MediaSessionCompat +import androidx.core.graphics.drawable.toBitmap +import coil.ImageLoader +import coil.request.ImageRequest +import com.google.android.exoplayer2.ForwardingPlayer +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.ui.PlayerNotificationManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.mobile.R +import org.jellyfin.mobile.utils.Constants.MEDIA_NOTIFICATION_CHANNEL_ID +import org.jellyfin.mobile.utils.Constants.MEDIA_PLAYER_NOTIFICATION_ID +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +/** + * A wrapper class for ExoPlayer's PlayerNotificationManager. It sets up the notification shown to + * the user during audio playback and provides track metadata, such as track title and icon image. + */ +class AudioNotificationManager( + private val context: Context, + sessionToken: MediaSessionCompat.Token, + notificationListener: PlayerNotificationManager.NotificationListener, +) : KoinComponent { + private val imageLoader: ImageLoader by inject() + + private val serviceJob = SupervisorJob() + private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob) + private val notificationManager: PlayerNotificationManager + + init { + val mediaController = MediaControllerCompat(context, sessionToken) + + notificationManager = PlayerNotificationManager + .Builder(context, MEDIA_PLAYER_NOTIFICATION_ID, MEDIA_NOTIFICATION_CHANNEL_ID) + .setChannelNameResourceId(R.string.music_notification_channel) + .setChannelDescriptionResourceId(R.string.music_notification_channel_description) + .setMediaDescriptionAdapter(DescriptionAdapter(mediaController)) + .setNotificationListener(notificationListener) + .build() + + notificationManager.apply { + setMediaSessionToken(sessionToken) + setSmallIcon(R.drawable.ic_notification) + } + } + + fun showNotificationForPlayer(player: Player) { + notificationManager.setPlayer(NotificationForwardingPlayer(player)) + } + + fun hideNotification() { + notificationManager.setPlayer(null) + } + + /** + * Removes rewind and fast-forward buttons from notification + */ + private class NotificationForwardingPlayer(player: Player) : ForwardingPlayer(player) { + override fun getAvailableCommands(): Player.Commands = super.getAvailableCommands().buildUpon().removeAll( + COMMAND_SEEK_BACK, + COMMAND_SEEK_FORWARD, + ).build() + } + + private inner class DescriptionAdapter( + private val controller: MediaControllerCompat, + ) : PlayerNotificationManager.MediaDescriptionAdapter { + + var currentIconUri: Uri? = null + var currentBitmap: Bitmap? = null + + override fun createCurrentContentIntent(player: Player): PendingIntent? = + controller.sessionActivity + + override fun getCurrentContentText(player: Player) = + controller.metadata.description.subtitle.toString() + + override fun getCurrentContentTitle(player: Player) = + controller.metadata.description.title.toString() + + override fun getCurrentLargeIcon(player: Player, callback: PlayerNotificationManager.BitmapCallback): Bitmap? { + val iconUri = controller.metadata.description.iconUri + return when { + currentIconUri != iconUri || currentBitmap == null -> { + // Cache the bitmap for the current song so that successive calls to + // `getCurrentLargeIcon` don't cause the bitmap to be recreated. + currentIconUri = iconUri + serviceScope.launch { + currentBitmap = iconUri?.let { + resolveUriAsBitmap(it) + } + currentBitmap?.let { callback.onBitmap(it) } + } + null + } + else -> currentBitmap + } + } + + private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? = withContext(Dispatchers.IO) { + imageLoader.execute(ImageRequest.Builder(context).data(uri).build()).drawable?.toBitmap() + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/audio/MediaService.kt b/app/src/main/java/org/jellyfin/mobile/player/audio/MediaService.kt new file mode 100644 index 0000000..98401c2 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/audio/MediaService.kt @@ -0,0 +1,429 @@ +// Contains code adapted from https://github.com/android/uamp/blob/main/common/src/main/java/com/example/android/uamp/media/MediaService.kt + +package org.jellyfin.mobile.player.audio + +import android.app.Notification +import android.app.PendingIntent +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.os.ResultReceiver +import android.support.v4.media.MediaBrowserCompat.MediaItem +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaControllerCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.media.MediaBrowserServiceCompat +import androidx.mediarouter.media.MediaControlIntent +import androidx.mediarouter.media.MediaRouteSelector +import androidx.mediarouter.media.MediaRouter +import androidx.mediarouter.media.MediaRouterParams +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.ExoPlayer +import com.google.android.exoplayer2.PlaybackException +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.audio.AudioAttributes +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector +import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator +import com.google.android.exoplayer2.source.MediaSource +import com.google.android.exoplayer2.ui.PlayerNotificationManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import org.jellyfin.mobile.R +import org.jellyfin.mobile.app.ApiClientController +import org.jellyfin.mobile.player.audio.car.LibraryBrowser +import org.jellyfin.mobile.player.audio.car.LibraryPage +import org.jellyfin.mobile.player.cast.CastPlayerProvider +import org.jellyfin.mobile.player.cast.ICastPlayerProvider +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.utils.extensions.mediaUri +import org.jellyfin.mobile.utils.toast +import org.jellyfin.sdk.api.client.exception.ApiClientException +import org.koin.android.ext.android.get +import org.koin.android.ext.android.inject +import timber.log.Timber +import com.google.android.exoplayer2.MediaItem as ExoPlayerMediaItem + +class MediaService : MediaBrowserServiceCompat() { + private val apiClientController: ApiClientController by inject() + private val libraryBrowser: LibraryBrowser by inject() + + private val serviceScope = MainScope() + private var isForegroundService = false + + private lateinit var loadingJob: Job + + // The current player will either be an ExoPlayer (for local playback) or a CastPlayer (for + // remote playback through a Cast device). + private lateinit var currentPlayer: Player + + private lateinit var notificationManager: AudioNotificationManager + private lateinit var mediaController: MediaControllerCompat + private lateinit var mediaSession: MediaSessionCompat + private lateinit var mediaSessionConnector: MediaSessionConnector + private lateinit var mediaRouteSelector: MediaRouteSelector + private lateinit var mediaRouter: MediaRouter + private val mediaRouterCallback = MediaRouterCallback() + + private var currentPlaylistItems: List = emptyList() + + private val playerAudioAttributes = AudioAttributes.Builder() + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .setUsage(C.USAGE_MEDIA) + .build() + + @Suppress("MemberVisibilityCanBePrivate") + val playerListener: Player.Listener = PlayerEventListener() + + private val exoPlayer: Player by lazy { + ExoPlayer.Builder(this, get()).apply { + setUsePlatformDiagnostics(false) + }.build().apply { + setAudioAttributes(playerAudioAttributes, true) + setHandleAudioBecomingNoisy(true) + addListener(playerListener) + } + } + + private val castPlayerProvider: ICastPlayerProvider by lazy { + CastPlayerProvider(this) + } + + override fun onCreate() { + super.onCreate() + + loadingJob = serviceScope.launch { + apiClientController.loadSavedServerUser() + } + + val sessionActivityPendingIntent = packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent -> + PendingIntent.getActivity(this, 0, sessionIntent, Constants.PENDING_INTENT_FLAGS) + } + + mediaSession = MediaSessionCompat(this, "MediaService").apply { + setSessionActivity(sessionActivityPendingIntent) + isActive = true + } + + sessionToken = mediaSession.sessionToken + + notificationManager = AudioNotificationManager( + this, + mediaSession.sessionToken, + PlayerNotificationListener(), + ) + + mediaController = MediaControllerCompat(this, mediaSession) + + mediaSessionConnector = MediaSessionConnector(mediaSession).apply { + setPlayer(exoPlayer) + setPlaybackPreparer(MediaPlaybackPreparer()) + setQueueNavigator(MediaQueueNavigator(mediaSession)) + } + + mediaRouter = MediaRouter.getInstance(this) + mediaRouter.setMediaSessionCompat(mediaSession) + mediaRouteSelector = MediaRouteSelector.Builder().apply { + addControlCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK) + }.build() + mediaRouter.routerParams = MediaRouterParams.Builder().apply { + setTransferToLocalEnabled(true) + }.build() + mediaRouter.addCallback(mediaRouteSelector, mediaRouterCallback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY) + + switchToPlayer( + previousPlayer = null, + newPlayer = if (castPlayerProvider.isCastSessionAvailable) castPlayerProvider.get()!! else exoPlayer, + ) + notificationManager.showNotificationForPlayer(currentPlayer) + } + + override fun onDestroy() { + mediaSession.run { + isActive = false + release() + } + + // Cancel coroutines when the service is going away + serviceScope.cancel() + + // Free ExoPlayer resources + exoPlayer.removeListener(playerListener) + exoPlayer.release() + + // Stop listening for route changes. + mediaRouter.removeCallback(mediaRouterCallback) + } + + override fun onGetRoot( + clientPackageName: String, + clientUid: Int, + rootHints: Bundle?, + ): BrowserRoot = libraryBrowser.getRoot(rootHints) + + override fun onLoadChildren(parentId: String, result: Result>) { + result.detach() + + serviceScope.launch(Dispatchers.IO) { + // Ensure that server and credentials are available + loadingJob.join() + + val items = try { + libraryBrowser.loadLibrary(parentId) + } catch (e: ApiClientException) { + Timber.e(e) + null + } + result.sendResult(items ?: emptyList()) + } + } + + /** + * Load the supplied list of songs and the song to play into the current player. + */ + private fun preparePlaylist( + metadataList: List, + initialPlaybackIndex: Int = 0, + playWhenReady: Boolean, + playbackStartPositionMs: Long = 0, + ) { + currentPlaylistItems = metadataList + + val mediaItems = metadataList.map { metadata -> + ExoPlayerMediaItem.Builder().apply { + setUri(metadata.mediaUri) + setTag(metadata) + }.build() + } + + currentPlayer.playWhenReady = playWhenReady + with(currentPlayer) { + stop() + clearMediaItems() + } + if (currentPlayer == exoPlayer) { + with(exoPlayer) { + setMediaItems(mediaItems) + prepare() + seekTo(initialPlaybackIndex, playbackStartPositionMs) + } + } else { + val castPlayer = castPlayerProvider.get() + if (currentPlayer == castPlayer) { + castPlayer.setMediaItems( + mediaItems, + initialPlaybackIndex, + playbackStartPositionMs, + ) + } + } + } + + private fun switchToPlayer(previousPlayer: Player?, newPlayer: Player) { + if (previousPlayer == newPlayer) { + return + } + currentPlayer = newPlayer + if (previousPlayer != null) { + val playbackState = previousPlayer.playbackState + if (currentPlaylistItems.isEmpty()) { + // We are joining a playback session. + // Loading the session from the new player is not supported, so we stop playback. + with(currentPlayer) { + stop() + clearMediaItems() + } + } else if (playbackState != Player.STATE_IDLE && playbackState != Player.STATE_ENDED) { + preparePlaylist( + metadataList = currentPlaylistItems, + initialPlaybackIndex = previousPlayer.currentMediaItemIndex, + playWhenReady = previousPlayer.playWhenReady, + playbackStartPositionMs = previousPlayer.currentPosition, + ) + } + } + mediaSessionConnector.setPlayer(newPlayer) + previousPlayer?.run { + stop() + clearMediaItems() + } + } + + private fun setPlaybackError() { + val errorState = PlaybackStateCompat.Builder() + .setState(PlaybackStateCompat.STATE_ERROR, 0, 1f) + .setErrorMessage( + PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED, + getString(R.string.media_service_item_not_found), + ) + .build() + mediaSession.setPlaybackState(errorState) + } + + @Suppress("unused") + fun onCastSessionAvailable() { + val castPlayer = castPlayerProvider.get() ?: return + switchToPlayer(currentPlayer, castPlayer) + } + + @Suppress("unused") + fun onCastSessionUnavailable() { + switchToPlayer(currentPlayer, exoPlayer) + } + + private inner class MediaQueueNavigator(mediaSession: MediaSessionCompat) : TimelineQueueNavigator(mediaSession) { + override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat = + currentPlaylistItems[windowIndex].description + } + + private inner class MediaPlaybackPreparer : MediaSessionConnector.PlaybackPreparer { + override fun getSupportedPrepareActions(): Long = 0L or + PlaybackStateCompat.ACTION_PREPARE or + PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or + PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or + PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or + PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH + + override fun onPrepare(playWhenReady: Boolean) { + serviceScope.launch { + val recents = try { + libraryBrowser.getDefaultRecents() + } catch (e: ApiClientException) { + Timber.e(e) + null + } + if (recents != null) { + preparePlaylist(recents, 0, playWhenReady) + } else { + setPlaybackError() + } + } + } + + override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) { + if (mediaId == LibraryPage.RESUME) { + // Requested recents + onPrepare(playWhenReady) + } else { + serviceScope.launch { + val result = libraryBrowser.buildPlayQueue(mediaId) + if (result != null) { + val (playbackQueue, initialPlaybackIndex) = result + preparePlaylist(playbackQueue, initialPlaybackIndex, playWhenReady) + } else { + setPlaybackError() + } + } + } + } + + override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) { + if (query.isEmpty()) { + // No search provided, fallback to recents + onPrepare(playWhenReady) + } else { + serviceScope.launch { + val results = try { + libraryBrowser.getSearchResults(query, extras) + } catch (e: ApiClientException) { + Timber.e(e) + null + } + if (results != null) { + preparePlaylist(results, 0, playWhenReady) + } else { + setPlaybackError() + } + } + } + } + + override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) = Unit + + override fun onCommand( + player: Player, + command: String, + extras: Bundle?, + cb: ResultReceiver?, + ): Boolean = false + } + + /** + * Listen for notification events. + */ + private inner class PlayerNotificationListener : PlayerNotificationManager.NotificationListener { + override fun onNotificationPosted(notificationId: Int, notification: Notification, ongoing: Boolean) { + if (ongoing && !isForegroundService) { + val serviceIntent = Intent(applicationContext, this@MediaService.javaClass) + ContextCompat.startForegroundService(applicationContext, serviceIntent) + + startForeground(notificationId, notification) + isForegroundService = true + } + } + + override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) { + stopForeground(true) + isForegroundService = false + stopSelf() + } + } + + /** + * Listen for events from ExoPlayer. + */ + private inner class PlayerEventListener : Player.Listener { + override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { + when (playbackState) { + Player.STATE_BUFFERING, + Player.STATE_READY, + -> { + notificationManager.showNotificationForPlayer(currentPlayer) + if (playbackState == Player.STATE_READY) { + // TODO: When playing/paused save the current media item in persistent storage + // so that playback can be resumed between device reboots + + if (!playWhenReady) { + // If playback is paused we remove the foreground state which allows the + // notification to be dismissed. An alternative would be to provide a + // "close" button in the notification which stops playback and clears + // the notification. + stopForeground(false) + } + } + } + else -> notificationManager.hideNotification() + } + } + + override fun onPlayerError(error: PlaybackException) { + toast("${getString(R.string.media_service_generic_error)}: ${error.errorCodeName}", Toast.LENGTH_LONG) + } + } + + /** + * Listen for MediaRoute changes + */ + private inner class MediaRouterCallback : MediaRouter.Callback() { + override fun onRouteSelected(router: MediaRouter, route: MediaRouter.RouteInfo, reason: Int) { + if (reason == MediaRouter.UNSELECT_REASON_ROUTE_CHANGED) { + Timber.d("Unselected because route changed, continue playback") + } else if (reason == MediaRouter.UNSELECT_REASON_STOPPED) { + Timber.d("Unselected because route was stopped, stop playback") + currentPlayer.stop() + } + } + } + + companion object { + /** Declares that content style is supported */ + const val CONTENT_STYLE_SUPPORTED = "android.media.browse.CONTENT_STYLE_SUPPORTED" + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/audio/car/LibraryBrowser.kt b/app/src/main/java/org/jellyfin/mobile/player/audio/car/LibraryBrowser.kt new file mode 100644 index 0000000..22eceac --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/audio/car/LibraryBrowser.kt @@ -0,0 +1,457 @@ +package org.jellyfin.mobile.player.audio.car + +import android.content.Context +import android.net.Uri +import android.os.Bundle +import android.provider.MediaStore +import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaBrowserCompat.MediaItem.FLAG_BROWSABLE +import android.support.v4.media.MediaBrowserCompat.MediaItem.FLAG_PLAYABLE +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.MediaMetadataCompat +import androidx.media.MediaBrowserServiceCompat +import androidx.media.utils.MediaConstants +import org.jellyfin.mobile.R +import org.jellyfin.mobile.player.audio.MediaService +import org.jellyfin.mobile.utils.extensions.mediaId +import org.jellyfin.mobile.utils.extensions.setAlbum +import org.jellyfin.mobile.utils.extensions.setAlbumArtUri +import org.jellyfin.mobile.utils.extensions.setAlbumArtist +import org.jellyfin.mobile.utils.extensions.setArtist +import org.jellyfin.mobile.utils.extensions.setDisplayIconUri +import org.jellyfin.mobile.utils.extensions.setMediaId +import org.jellyfin.mobile.utils.extensions.setMediaUri +import org.jellyfin.mobile.utils.extensions.setTitle +import org.jellyfin.mobile.utils.extensions.setTrackNumber +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.ApiClientException +import org.jellyfin.sdk.api.client.extensions.genresApi +import org.jellyfin.sdk.api.client.extensions.imageApi +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.playlistsApi +import org.jellyfin.sdk.api.client.extensions.universalAudioApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.api.operations.GenresApi +import org.jellyfin.sdk.api.operations.ImageApi +import org.jellyfin.sdk.api.operations.ItemsApi +import org.jellyfin.sdk.api.operations.PlaylistsApi +import org.jellyfin.sdk.api.operations.UniversalAudioApi +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.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemFilter +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.MediaStreamProtocol +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.serializer.toUUID +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import java.util.* + +@Suppress("TooManyFunctions") +class LibraryBrowser( + private val context: Context, + private val apiClient: ApiClient, +) { + private val itemsApi: ItemsApi = apiClient.itemsApi + private val userViewsApi: UserViewsApi = apiClient.userViewsApi + private val genresApi: GenresApi = apiClient.genresApi + private val playlistsApi: PlaylistsApi = apiClient.playlistsApi + private val imageApi: ImageApi = apiClient.imageApi + private val universalAudioApi: UniversalAudioApi = apiClient.universalAudioApi + + fun getRoot(hints: Bundle?): MediaBrowserServiceCompat.BrowserRoot { + /** + * By default return the browsable root. Treat the EXTRA_RECENT flag as a special case + * and return the recent root instead. + */ + val isRecentRequest = hints?.getBoolean(MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT) ?: false + val browserRoot = if (isRecentRequest) LibraryPage.RESUME else LibraryPage.LIBRARIES + + val rootExtras = Bundle().apply { + putBoolean(MediaService.CONTENT_STYLE_SUPPORTED, true) + putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM, + ) + putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM, + ) + } + return MediaBrowserServiceCompat.BrowserRoot(browserRoot, rootExtras) + } + + suspend fun loadLibrary(parentId: String): List? { + if (parentId == LibraryPage.RESUME) { + return getDefaultRecents()?.browsable() + } + + val split = parentId.split('|') + + @Suppress("MagicNumber") + if (split.size !in 1..3) { + Timber.e("Invalid libraryId format '$parentId'") + return null + } + + val type = split[0] + val libraryId = split.getOrNull(1)?.toUUIDOrNull() + val itemId = split.getOrNull(2)?.toUUIDOrNull() + + return when { + libraryId != null -> { + when { + itemId != null -> when (type) { + LibraryPage.ARTIST_ALBUMS -> getAlbums(libraryId, filterArtist = itemId) + LibraryPage.GENRE_ALBUMS -> getAlbums(libraryId, filterGenre = itemId) + else -> null + } + else -> when (type) { + LibraryPage.LIBRARY -> getLibraryViews(context, libraryId) + LibraryPage.RECENTS -> getRecents(libraryId)?.playable() + LibraryPage.ALBUMS -> getAlbums(libraryId) + LibraryPage.ARTISTS -> getArtists(libraryId) + LibraryPage.GENRES -> getGenres(libraryId) + LibraryPage.PLAYLISTS -> getPlaylists(libraryId) + LibraryPage.ALBUM -> getAlbum(libraryId)?.playable() + LibraryPage.PLAYLIST -> getPlaylist(libraryId)?.playable() + else -> null + } + } + } + else -> when (type) { + LibraryPage.LIBRARIES -> getLibraries() + else -> null + } + } + } + + suspend fun buildPlayQueue(mediaId: String): Pair, Int>? { + val split = mediaId.split('|') + @Suppress("MagicNumber") + if (split.size != 3) { + Timber.e("Invalid mediaId format '$mediaId'") + return null + } + + val type = split[0] + val collectionId = split[1].toUUID() + + val playQueue = try { + when (type) { + LibraryPage.RECENTS -> getRecents(collectionId) + LibraryPage.ALBUM -> getAlbum(collectionId) + LibraryPage.PLAYLIST -> getPlaylist(collectionId) + else -> null + } + } catch (e: ApiClientException) { + Timber.e(e) + null + } ?: return null + + val playIndex = playQueue.indexOfFirst { item -> + item.mediaId == mediaId + }.coerceAtLeast(0) + + return playQueue to playIndex + } + + suspend fun getSearchResults(searchQuery: String, extras: Bundle?): List? { + when (extras?.getString(MediaStore.EXTRA_MEDIA_FOCUS)) { + MediaStore.Audio.Albums.ENTRY_CONTENT_TYPE -> { + // Search for specific album + extras.getString(MediaStore.EXTRA_MEDIA_ALBUM)?.let { albumQuery -> + Timber.d("Searching for album $albumQuery") + searchItems(albumQuery, BaseItemKind.MUSIC_ALBUM) + }?.let { albumId -> + getAlbum(albumId) + }?.let { albumContent -> + Timber.d("Got result, starting playback") + return albumContent + } + } + MediaStore.Audio.Artists.ENTRY_CONTENT_TYPE -> { + // Search for specific artist + extras.getString(MediaStore.EXTRA_MEDIA_ARTIST)?.let { artistQuery -> + Timber.d("Searching for artist $artistQuery") + searchItems(artistQuery, BaseItemKind.MUSIC_ARTIST) + }?.let { artistId -> + itemsApi.getItems( + artistIds = listOf(artistId), + includeItemTypes = listOf(BaseItemKind.AUDIO), + sortBy = listOf(ItemSortBy.RANDOM), + recursive = true, + imageTypeLimit = 1, + enableImageTypes = listOf(ImageType.PRIMARY), + enableTotalRecordCount = false, + limit = 50, + ).content.extractItems() + }?.let { artistTracks -> + Timber.d("Got result, starting playback") + return artistTracks + } + } + } + + // Fallback to generic search + Timber.d("Searching for '$searchQuery'") + val result by itemsApi.getItems( + searchTerm = searchQuery, + includeItemTypes = listOf(BaseItemKind.AUDIO), + recursive = true, + imageTypeLimit = 1, + enableImageTypes = listOf(ImageType.PRIMARY), + enableTotalRecordCount = false, + limit = 50, + ) + + return result.extractItems() + } + + /** + * Find a single specific item for the given [searchQuery] with a specific [type] + */ + private suspend fun searchItems(searchQuery: String, type: BaseItemKind): UUID? { + val result by itemsApi.getItems( + searchTerm = searchQuery, + includeItemTypes = listOf(type), + recursive = true, + enableImages = false, + enableTotalRecordCount = false, + limit = 1, + ) + + return result.items.firstOrNull()?.id + } + + suspend fun getDefaultRecents(): List? = getLibraries().firstOrNull()?.mediaId?.let { defaultLibrary -> + val libraryId = defaultLibrary.split('|').getOrNull(1) ?: return@let null + + getRecents(libraryId.toUUID()) + } + + private suspend fun getLibraries(): List { + val userViews by userViewsApi.getUserViews() + + return userViews.items + .filter { item -> item.collectionType == CollectionType.MUSIC } + .map { item -> + val itemImageUrl = imageApi.getItemImageUrl( + itemId = item.id, + imageType = ImageType.PRIMARY, + ) + + val description = MediaDescriptionCompat.Builder().apply { + setMediaId(LibraryPage.LIBRARY + "|" + item.id) + setTitle(item.name) + setIconUri(Uri.parse(itemImageUrl)) + }.build() + MediaBrowserCompat.MediaItem(description, FLAG_BROWSABLE) + } + .toList() + } + + private fun getLibraryViews(context: Context, libraryId: UUID): List { + val libraryViews = arrayOf( + LibraryPage.RECENTS to R.string.media_service_car_section_recents, + LibraryPage.ALBUMS to R.string.media_service_car_section_albums, + LibraryPage.ARTISTS to R.string.media_service_car_section_artists, + LibraryPage.GENRES to R.string.media_service_car_section_genres, + LibraryPage.PLAYLISTS to R.string.media_service_car_section_playlists, + ) + return libraryViews.map { item -> + val description = MediaDescriptionCompat.Builder().apply { + setMediaId(item.first + "|" + libraryId) + setTitle(context.getString(item.second)) + + if (item.first == LibraryPage.ALBUMS) { + setExtras( + Bundle().apply { + putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM, + ) + putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM, + ) + }, + ) + } + }.build() + MediaBrowserCompat.MediaItem(description, FLAG_BROWSABLE) + } + } + + private suspend fun getRecents(libraryId: UUID): List? { + val result by itemsApi.getItems( + parentId = libraryId, + includeItemTypes = listOf(BaseItemKind.AUDIO), + filters = listOf(ItemFilter.IS_PLAYED), + sortBy = listOf(ItemSortBy.DATE_PLAYED), + sortOrder = listOf(SortOrder.DESCENDING), + recursive = true, + imageTypeLimit = 1, + enableImageTypes = listOf(ImageType.PRIMARY), + enableTotalRecordCount = false, + limit = 50, + ) + + return result.extractItems("${LibraryPage.RECENTS}|$libraryId") + } + + private suspend fun getAlbums( + libraryId: UUID, + filterArtist: UUID? = null, + filterGenre: UUID? = null, + ): List? { + val result by itemsApi.getItems( + parentId = libraryId, + artistIds = filterArtist?.let(::listOf), + genreIds = filterGenre?.let(::listOf), + includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), + sortBy = listOf(ItemSortBy.SORT_NAME), + recursive = true, + imageTypeLimit = 1, + enableImageTypes = listOf(ImageType.PRIMARY), + limit = 400, + ) + + return result.extractItems()?.browsable() + } + + private suspend fun getArtists(libraryId: UUID): List? { + val result by itemsApi.getItems( + parentId = libraryId, + includeItemTypes = listOf(BaseItemKind.MUSIC_ARTIST), + sortBy = listOf(ItemSortBy.SORT_NAME), + recursive = true, + imageTypeLimit = 1, + enableImageTypes = listOf(ImageType.PRIMARY), + limit = 200, + ) + + return result.extractItems(libraryId.toString())?.browsable() + } + + private suspend fun getGenres(libraryId: UUID): List? { + val result by genresApi.getGenres( + parentId = libraryId, + imageTypeLimit = 1, + enableImageTypes = listOf(ImageType.PRIMARY), + limit = 50, + ) + + return result.extractItems(libraryId.toString())?.browsable() + } + + private suspend fun getPlaylists(libraryId: UUID): List? { + val result by itemsApi.getItems( + parentId = libraryId, + includeItemTypes = listOf(BaseItemKind.PLAYLIST), + sortBy = listOf(ItemSortBy.SORT_NAME), + recursive = true, + imageTypeLimit = 1, + enableImageTypes = listOf(ImageType.PRIMARY), + limit = 50, + ) + + return result.extractItems()?.browsable() + } + + private suspend fun getAlbum(albumId: UUID): List? { + val result by itemsApi.getItems( + parentId = albumId, + sortBy = listOf(ItemSortBy.SORT_NAME), + ) + + return result.extractItems("${LibraryPage.ALBUM}|$albumId") + } + + private suspend fun getPlaylist(playlistId: UUID): List? { + val result by playlistsApi.getPlaylistItems( + playlistId = playlistId, + ) + + return result.extractItems("${LibraryPage.PLAYLIST}|$playlistId") + } + + private fun BaseItemDtoQueryResult.extractItems(libraryId: String? = null): List? = + items?.map { item -> buildMediaMetadata(item, libraryId) }?.toList() + + private fun buildMediaMetadata(item: BaseItemDto, libraryId: String?): MediaMetadataCompat { + val builder = MediaMetadataCompat.Builder() + builder.setMediaId(buildMediaId(item, libraryId)) + builder.setTitle(item.name ?: context.getString(R.string.media_service_car_item_no_title)) + + val isAlbum = item.albumId != null + val itemId = when { + item.imageTags?.containsKey(ImageType.PRIMARY) == true -> item.id + isAlbum -> item.albumId + else -> null + } + val primaryImageUrl = itemId?.let { + imageApi.getItemImageUrl( + itemId = itemId, + imageType = ImageType.PRIMARY, + tag = if (isAlbum) item.albumPrimaryImageTag else item.imageTags?.get(ImageType.PRIMARY), + ) + } + + if (item.type == BaseItemKind.AUDIO) { + val uri = universalAudioApi.getUniversalAudioStreamUrl( + itemId = item.id, + deviceId = apiClient.deviceInfo.id, + maxStreamingBitrate = 140000000, + container = listOf( + "opus", + "mp3|mp3", + "aac", + "m4a", + "m4b|aac", + "flac", + "webma", + "webm", + "wav", + "ogg", + ), + transcodingProtocol = MediaStreamProtocol.HLS, + transcodingContainer = "ts", + audioCodec = "aac", + enableRemoteMedia = true, + ) + + builder.setMediaUri(uri) + item.album?.let(builder::setAlbum) + item.artists?.let { builder.setArtist(it.joinToString()) } + item.albumArtist?.let(builder::setAlbumArtist) + primaryImageUrl?.let(builder::setAlbumArtUri) + item.indexNumber?.toLong()?.let(builder::setTrackNumber) + } else { + primaryImageUrl?.let(builder::setDisplayIconUri) + } + + return builder.build() + } + + private fun buildMediaId(item: BaseItemDto, extra: String?) = when (item.type) { + BaseItemKind.MUSIC_ARTIST -> "${LibraryPage.ARTIST_ALBUMS}|$extra|${item.id}" + BaseItemKind.MUSIC_GENRE -> "${LibraryPage.GENRE_ALBUMS}|$extra|${item.id}" + BaseItemKind.MUSIC_ALBUM -> "${LibraryPage.ALBUM}|${item.id}" + BaseItemKind.PLAYLIST -> "${LibraryPage.PLAYLIST}|${item.id}" + BaseItemKind.AUDIO -> "$extra|${item.id}" + else -> throw IllegalArgumentException("Unhandled item type ${item.type}") + } + + private fun List.browsable(): List = map { metadata -> + MediaBrowserCompat.MediaItem(metadata.description, FLAG_BROWSABLE) + } + + private fun List.playable(): List = map { metadata -> + MediaBrowserCompat.MediaItem(metadata.description, FLAG_PLAYABLE) + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/audio/car/LibraryPage.kt b/app/src/main/java/org/jellyfin/mobile/player/audio/car/LibraryPage.kt new file mode 100644 index 0000000..99b7283 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/audio/car/LibraryPage.kt @@ -0,0 +1,63 @@ +package org.jellyfin.mobile.player.audio.car + +object LibraryPage { + /** + * List of music libraries that the user can access (referred to as "user views" in Jellyfin) + */ + const val LIBRARIES = "libraries" + + /** + * Special root id for use with [EXTRA_RECENT][androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT] + */ + const val RESUME = "resume" + + /** + * A single music library + */ + const val LIBRARY = "library" + + /** + * A list of recently added tracks + */ + const val RECENTS = "recents" + + /** + * A list of albums + */ + const val ALBUMS = "albums" + + /** + * A list of artists + */ + const val ARTISTS = "artists" + + /** + * A list of albums by a specific artist + */ + const val ARTIST_ALBUMS = "artist_albums" + + /** + * A list of genres + */ + const val GENRES = "genres" + + /** + * A list of albums with a specific genre + */ + const val GENRE_ALBUMS = "genre_albums" + + /** + * A list of playlists + */ + const val PLAYLISTS = "playlists" + + /** + * An individual album + */ + const val ALBUM = "album" + + /** + * An individual playlist + */ + const val PLAYLIST = "playlist" +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/cast/ICastPlayerProvider.kt b/app/src/main/java/org/jellyfin/mobile/player/cast/ICastPlayerProvider.kt new file mode 100644 index 0000000..3be6d01 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/cast/ICastPlayerProvider.kt @@ -0,0 +1,9 @@ +package org.jellyfin.mobile.player.cast + +import com.google.android.exoplayer2.Player + +interface ICastPlayerProvider { + val isCastSessionAvailable: Boolean + + fun get(): Player? +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/cast/IChromecast.kt b/app/src/main/java/org/jellyfin/mobile/player/cast/IChromecast.kt new file mode 100644 index 0000000..3517937 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/cast/IChromecast.kt @@ -0,0 +1,15 @@ +package org.jellyfin.mobile.player.cast + +import android.app.Activity +import org.jellyfin.mobile.bridge.JavascriptCallback +import org.json.JSONArray +import org.json.JSONException + +interface IChromecast { + fun initializePlugin(activity: Activity) + + @Throws(JSONException::class) + fun execute(action: String, args: JSONArray, cbContext: JavascriptCallback): Boolean + + fun destroy() +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/deviceprofile/CodecHelpers.kt b/app/src/main/java/org/jellyfin/mobile/player/deviceprofile/CodecHelpers.kt new file mode 100644 index 0000000..3de90ef --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/deviceprofile/CodecHelpers.kt @@ -0,0 +1,272 @@ +package org.jellyfin.mobile.player.deviceprofile + +import android.media.MediaCodecInfo.CodecProfileLevel +import android.media.MediaFormat +import com.google.android.exoplayer2.util.MimeTypes + +@Suppress("TooManyFunctions", "CyclomaticComplexMethod") +object CodecHelpers { + fun getVideoCodec(mimeType: String): String? = when (mimeType) { + MediaFormat.MIMETYPE_VIDEO_MPEG2 -> "mpeg2video" + MediaFormat.MIMETYPE_VIDEO_H263 -> "h263" + MediaFormat.MIMETYPE_VIDEO_MPEG4 -> "mpeg4" + MediaFormat.MIMETYPE_VIDEO_AVC -> "h264" + MediaFormat.MIMETYPE_VIDEO_HEVC, MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION -> "hevc" + MediaFormat.MIMETYPE_VIDEO_VP8 -> "vp8" + MediaFormat.MIMETYPE_VIDEO_VP9 -> "vp9" + MediaFormat.MIMETYPE_VIDEO_AV1 -> "av1" + else -> null + } + + fun getAudioCodec(mimeType: String): String? = when (mimeType) { + MediaFormat.MIMETYPE_AUDIO_AAC -> "aac" + MediaFormat.MIMETYPE_AUDIO_AC3 -> "ac3" + MediaFormat.MIMETYPE_AUDIO_AMR_WB, MediaFormat.MIMETYPE_AUDIO_AMR_NB -> "3gpp" + MediaFormat.MIMETYPE_AUDIO_EAC3 -> "eac3" + MediaFormat.MIMETYPE_AUDIO_FLAC -> "flac" + MediaFormat.MIMETYPE_AUDIO_MPEG -> "mp3" + MediaFormat.MIMETYPE_AUDIO_OPUS -> "opus" + MediaFormat.MIMETYPE_AUDIO_RAW -> "raw" + MediaFormat.MIMETYPE_AUDIO_VORBIS -> "vorbis" + MediaFormat.MIMETYPE_AUDIO_QCELP, MediaFormat.MIMETYPE_AUDIO_MSGSM, MediaFormat.MIMETYPE_AUDIO_G711_MLAW, MediaFormat.MIMETYPE_AUDIO_G711_ALAW -> null + else -> null + } + + fun getVideoProfile(codec: String, profile: Int): String? = when (codec) { + "mpeg2video" -> getMPEG2VideoProfile(profile) + "h263" -> getH263Profile(profile) + "mpeg4" -> getMPEG4Profile(profile) + "h264" -> getAVCProfile(profile) + "hevc" -> getHEVCProfile(profile) + "vp8" -> getVP8Profile(profile) + "vp9" -> getVP9Profile(profile) + else -> null + } + + private fun getMPEG2VideoProfile(profile: Int): String? = when (profile) { + CodecProfileLevel.MPEG2ProfileSimple -> "simple profile" + CodecProfileLevel.MPEG2ProfileMain -> "main profile" + CodecProfileLevel.MPEG2Profile422 -> "422 profile" + CodecProfileLevel.MPEG2ProfileSNR -> "snr profile" + CodecProfileLevel.MPEG2ProfileSpatial -> "spatial profile" + CodecProfileLevel.MPEG2ProfileHigh -> "high profile" + else -> null + } + + private fun getH263Profile(profile: Int): String? = when (profile) { + CodecProfileLevel.H263ProfileBaseline -> "baseline" + CodecProfileLevel.H263ProfileH320Coding -> "h320 coding" + CodecProfileLevel.H263ProfileBackwardCompatible -> "backward compatible" + CodecProfileLevel.H263ProfileISWV2 -> "isw v2" + CodecProfileLevel.H263ProfileISWV3 -> "isw v3" + CodecProfileLevel.H263ProfileHighCompression -> "high compression" + CodecProfileLevel.H263ProfileInternet -> "internet" + CodecProfileLevel.H263ProfileInterlace -> "interlace" + CodecProfileLevel.H263ProfileHighLatency -> "high latency" + else -> null + } + + private fun getMPEG4Profile(profile: Int): String? = when (profile) { + CodecProfileLevel.MPEG4ProfileAdvancedCoding -> "advanced coding profile" + CodecProfileLevel.MPEG4ProfileAdvancedCore -> "advanced core profile" + CodecProfileLevel.MPEG4ProfileAdvancedRealTime -> "advanced realtime profile" + CodecProfileLevel.MPEG4ProfileAdvancedSimple -> "advanced simple profile" + CodecProfileLevel.MPEG4ProfileBasicAnimated -> "basic animated profile" + CodecProfileLevel.MPEG4ProfileCore -> "core profile" + CodecProfileLevel.MPEG4ProfileCoreScalable -> "core scalable profile" + CodecProfileLevel.MPEG4ProfileHybrid -> "hybrid profile" + CodecProfileLevel.MPEG4ProfileNbit -> "nbit profile" + CodecProfileLevel.MPEG4ProfileScalableTexture -> "scalable texture profile" + CodecProfileLevel.MPEG4ProfileSimple -> "simple profile" + CodecProfileLevel.MPEG4ProfileSimpleFBA -> "simple fba profile" + CodecProfileLevel.MPEG4ProfileSimpleFace -> "simple face profile" + CodecProfileLevel.MPEG4ProfileSimpleScalable -> "simple scalable profile" + CodecProfileLevel.MPEG4ProfileMain -> "main profile" + else -> null + } + + private fun getAVCProfile(profile: Int): String? = when (profile) { + CodecProfileLevel.AVCProfileBaseline -> "baseline" + CodecProfileLevel.AVCProfileMain -> "main" + CodecProfileLevel.AVCProfileExtended -> "extended" + CodecProfileLevel.AVCProfileHigh -> "high" + CodecProfileLevel.AVCProfileHigh10 -> "high 10" + CodecProfileLevel.AVCProfileHigh422 -> "high 422" + CodecProfileLevel.AVCProfileHigh444 -> "high 444" + CodecProfileLevel.AVCProfileConstrainedBaseline -> "constrained baseline" + CodecProfileLevel.AVCProfileConstrainedHigh -> "constrained high" + else -> null + } + + private fun getHEVCProfile(profile: Int): String? = when (profile) { + CodecProfileLevel.HEVCProfileMain -> "Main" + CodecProfileLevel.HEVCProfileMain10 -> "Main 10" + CodecProfileLevel.HEVCProfileMain10HDR10 -> "Main 10 HDR 10" + CodecProfileLevel.HEVCProfileMain10HDR10Plus -> "Main 10 HDR 10 Plus" + CodecProfileLevel.HEVCProfileMainStill -> "Main Still" + else -> null + } + + private fun getVP8Profile(profile: Int): String? = when (profile) { + CodecProfileLevel.VP8ProfileMain -> "main" + else -> null + } + + private fun getVP9Profile(profile: Int): String? = when (profile) { + CodecProfileLevel.VP9Profile0 -> "Profile 0" + CodecProfileLevel.VP9Profile1 -> "Profile 1" + CodecProfileLevel.VP9Profile2, + CodecProfileLevel.VP9Profile2HDR, + -> "Profile 2" + CodecProfileLevel.VP9Profile3, + CodecProfileLevel.VP9Profile3HDR, + -> "Profile 3" + else -> null + } + + fun getVideoLevel(codec: String, level: Int): Int? = when (codec) { + "mpeg2video" -> getMPEG2VideoLevel(level) + "h263" -> getH263Level(level) + "mpeg4" -> getMPEG4Level(level) + "avc", "h264" -> getAVCLevel(level) + "hevc" -> getHEVCLevel(level) + "vp8" -> getVP8Level(level) + "vp9" -> getVP9Level(level) + else -> null + }?.let { Integer.valueOf(it) } + + /** + * Level numbers taken from FFmpeg `libavcodec/mpeg12enc.c`. + */ + private fun getMPEG2VideoLevel(level: Int): String? = when (level) { + CodecProfileLevel.MPEG2LevelLL -> "10" + CodecProfileLevel.MPEG2LevelML -> "8" + CodecProfileLevel.MPEG2LevelH14 -> "6" + CodecProfileLevel.MPEG2LevelHL -> "4" + else -> null + } + + private fun getH263Level(level: Int): String? = when (level) { + CodecProfileLevel.H263Level10 -> "10" + CodecProfileLevel.H263Level20 -> "20" + CodecProfileLevel.H263Level30 -> "30" + CodecProfileLevel.H263Level40 -> "40" + CodecProfileLevel.H263Level45 -> "45" + CodecProfileLevel.H263Level50 -> "50" + CodecProfileLevel.H263Level60 -> "60" + CodecProfileLevel.H263Level70 -> "70" + else -> null + } + + private fun getMPEG4Level(level: Int): String? = when (level) { + CodecProfileLevel.MPEG4Level0 -> "0" + CodecProfileLevel.MPEG4Level1 -> "1" + CodecProfileLevel.MPEG4Level2 -> "2" + CodecProfileLevel.MPEG4Level3 -> "3" + CodecProfileLevel.MPEG4Level4 -> "4" + CodecProfileLevel.MPEG4Level5 -> "5" + CodecProfileLevel.MPEG4Level6 -> "6" + CodecProfileLevel.MPEG4Level0b, CodecProfileLevel.MPEG4Level3b, CodecProfileLevel.MPEG4Level4a -> null + else -> null + } + + private fun getAVCLevel(level: Int): String? = when (level) { + CodecProfileLevel.AVCLevel1 -> "1" + CodecProfileLevel.AVCLevel11 -> "11" + CodecProfileLevel.AVCLevel12 -> "12" + CodecProfileLevel.AVCLevel13 -> "13" + CodecProfileLevel.AVCLevel2 -> "2" + CodecProfileLevel.AVCLevel21 -> "21" + CodecProfileLevel.AVCLevel22 -> "22" + CodecProfileLevel.AVCLevel3 -> "3" + CodecProfileLevel.AVCLevel31 -> "31" + CodecProfileLevel.AVCLevel32 -> "32" + CodecProfileLevel.AVCLevel4 -> "4" + CodecProfileLevel.AVCLevel41 -> "41" + CodecProfileLevel.AVCLevel42 -> "42" + CodecProfileLevel.AVCLevel5 -> "5" + CodecProfileLevel.AVCLevel51 -> "51" + CodecProfileLevel.AVCLevel52 -> "52" + CodecProfileLevel.AVCLevel1b -> null + else -> null + } + + private fun getHEVCLevel(level: Int): String? = when (level) { + CodecProfileLevel.HEVCMainTierLevel1, CodecProfileLevel.HEVCHighTierLevel1 -> "30" + CodecProfileLevel.HEVCMainTierLevel2, CodecProfileLevel.HEVCHighTierLevel2 -> "60" + CodecProfileLevel.HEVCMainTierLevel21, CodecProfileLevel.HEVCHighTierLevel21 -> "63" + CodecProfileLevel.HEVCMainTierLevel3, CodecProfileLevel.HEVCHighTierLevel3 -> "90" + CodecProfileLevel.HEVCMainTierLevel31, CodecProfileLevel.HEVCHighTierLevel31 -> "93" + CodecProfileLevel.HEVCMainTierLevel4, CodecProfileLevel.HEVCHighTierLevel4 -> "120" + CodecProfileLevel.HEVCMainTierLevel41, CodecProfileLevel.HEVCHighTierLevel41 -> "123" + CodecProfileLevel.HEVCMainTierLevel5, CodecProfileLevel.HEVCHighTierLevel5 -> "150" + CodecProfileLevel.HEVCMainTierLevel51, CodecProfileLevel.HEVCHighTierLevel51 -> "153" + CodecProfileLevel.HEVCMainTierLevel52, CodecProfileLevel.HEVCHighTierLevel52 -> "156" + CodecProfileLevel.HEVCMainTierLevel6, CodecProfileLevel.HEVCHighTierLevel6 -> "180" + CodecProfileLevel.HEVCMainTierLevel61, CodecProfileLevel.HEVCHighTierLevel61 -> "183" + CodecProfileLevel.HEVCMainTierLevel62, CodecProfileLevel.HEVCHighTierLevel62 -> "186" + else -> null + } + + private fun getVP8Level(level: Int): String? = when (level) { + CodecProfileLevel.VP8Level_Version0 -> "0" + CodecProfileLevel.VP8Level_Version1 -> "1" + CodecProfileLevel.VP8Level_Version2 -> "2" + CodecProfileLevel.VP8Level_Version3 -> "3" + else -> null + } + + private fun getVP9Level(level: Int): String? = when (level) { + CodecProfileLevel.VP9Level1 -> "1" + CodecProfileLevel.VP9Level11 -> "11" + CodecProfileLevel.VP9Level2 -> "2" + CodecProfileLevel.VP9Level21 -> "21" + CodecProfileLevel.VP9Level3 -> "3" + CodecProfileLevel.VP9Level31 -> "31" + CodecProfileLevel.VP9Level4 -> "4" + CodecProfileLevel.VP9Level41 -> "41" + CodecProfileLevel.VP9Level5 -> "5" + CodecProfileLevel.VP9Level51 -> "51" + CodecProfileLevel.VP9Level52 -> "52" + CodecProfileLevel.VP9Level6 -> "6" + CodecProfileLevel.VP9Level61 -> "61" + CodecProfileLevel.VP9Level62 -> "62" + else -> null + } + + /** + * Get the mimeType for a subtitle codec if supported. + * + * @param codec Subtitle codec given by Jellyfin. + * @return The mimeType or null if not supported. + */ + fun getSubtitleMimeType(codec: String?): String? { + return when (codec) { + "srt", "subrip" -> MimeTypes.APPLICATION_SUBRIP + "ssa", "ass" -> MimeTypes.TEXT_SSA + "ttml" -> MimeTypes.APPLICATION_TTML + "vtt", "webvtt" -> MimeTypes.TEXT_VTT + "idx", "sub" -> MimeTypes.APPLICATION_VOBSUB + "pgs", "pgssub" -> MimeTypes.APPLICATION_PGS + "smi", "smil" -> "application/smil+xml" + else -> null + } + } + + fun getAudioProfile(codec: String, profile: Int): String? = when (codec) { + "aac" -> getAACProfile(profile) + else -> null + } + + private fun getAACProfile(profile: Int): String? = when (profile) { + CodecProfileLevel.AACObjectELD -> "ELD" + CodecProfileLevel.AACObjectHE -> "HE-AAC" + CodecProfileLevel.AACObjectHE_PS -> "HE-AACv2" + CodecProfileLevel.AACObjectLC -> "LC" + CodecProfileLevel.AACObjectLD -> "LD" + CodecProfileLevel.AACObjectLTP -> "LTP" + CodecProfileLevel.AACObjectMain -> "Main" + CodecProfileLevel.AACObjectSSR -> "SSR" + else -> null + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/deviceprofile/DeviceCodec.kt b/app/src/main/java/org/jellyfin/mobile/player/deviceprofile/DeviceCodec.kt new file mode 100644 index 0000000..25259bd --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/deviceprofile/DeviceCodec.kt @@ -0,0 +1,104 @@ +package org.jellyfin.mobile.player.deviceprofile + +import android.media.MediaCodecInfo.CodecCapabilities +import android.util.Range +import org.jellyfin.mobile.player.deviceprofile.CodecHelpers.getAudioCodec +import org.jellyfin.mobile.player.deviceprofile.CodecHelpers.getAudioProfile +import org.jellyfin.mobile.player.deviceprofile.CodecHelpers.getVideoCodec +import org.jellyfin.mobile.player.deviceprofile.CodecHelpers.getVideoLevel +import org.jellyfin.mobile.player.deviceprofile.CodecHelpers.getVideoProfile +import kotlin.math.max + +sealed class DeviceCodec( + val name: String, + val mimeType: String, + val profiles: Set, + val maxBitrate: Int, +) { + class Video( + name: String, + mimeType: String, + profiles: Set, + private val levels: Set, + maxBitrate: Int, + ) : DeviceCodec(name, mimeType, profiles, maxBitrate) { + + fun mergeCodec(codecToMerge: Video): Video = Video( + name = name, + mimeType = mimeType, + profiles = profiles + codecToMerge.profiles, + levels = levels + codecToMerge.levels, + maxBitrate = max(maxBitrate, codecToMerge.maxBitrate), + ) + } + + class Audio( + name: String, + mimeType: String, + profiles: Set, + maxBitrate: Int, + private val maxChannels: Int, + private val maxSampleRate: Int?, + ) : DeviceCodec(name, mimeType, profiles, maxBitrate) { + + fun mergeCodec(codecToMerge: Audio): Audio = Audio( + name = name, + mimeType = mimeType, + profiles = profiles + codecToMerge.profiles, + maxBitrate = max(maxBitrate, codecToMerge.maxBitrate), + maxChannels = max(maxChannels, codecToMerge.maxChannels), + maxSampleRate = when { + maxSampleRate != null -> when { + codecToMerge.maxSampleRate != null -> max(maxSampleRate, codecToMerge.maxSampleRate) + else -> maxSampleRate + } + else -> codecToMerge.maxSampleRate + }, + ) + } + + companion object { + fun from(codecCapabilities: CodecCapabilities): DeviceCodec? { + val mimeType = codecCapabilities.mimeType + + // Check if this mimeType represents a video or audio codec + val videoCodec = getVideoCodec(mimeType) + val audioCodec = getAudioCodec(mimeType) + return when { + videoCodec != null -> { + val profiles = HashSet() + val levels = HashSet() + for (profileLevel in codecCapabilities.profileLevels) { + getVideoProfile(videoCodec, profileLevel.profile)?.let(profiles::add) + getVideoLevel(videoCodec, profileLevel.level)?.let(levels::add) + } + + Video( + name = videoCodec, + mimeType = mimeType, + profiles = profiles, + levels = levels, + maxBitrate = codecCapabilities.videoCapabilities.bitrateRange.upper, + ) + } + audioCodec != null -> { + val profiles = HashSet() + for (profileLevel in codecCapabilities.profileLevels) { + getAudioProfile(audioCodec, profileLevel.profile)?.let(profiles::add) + } + + Audio( + name = audioCodec, + mimeType = mimeType, + profiles = profiles, + maxBitrate = codecCapabilities.audioCapabilities.bitrateRange.upper, + maxChannels = codecCapabilities.audioCapabilities.maxInputChannelCount, + maxSampleRate = codecCapabilities.audioCapabilities.supportedSampleRateRanges + .maxOfOrNull(Range::getUpper), + ) + } + else -> return null + } + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/deviceprofile/DeviceProfileBuilder.kt b/app/src/main/java/org/jellyfin/mobile/player/deviceprofile/DeviceProfileBuilder.kt new file mode 100644 index 0000000..17ef90c --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/deviceprofile/DeviceProfileBuilder.kt @@ -0,0 +1,300 @@ +package org.jellyfin.mobile.player.deviceprofile + +import android.media.MediaCodecList +import org.jellyfin.mobile.app.AppPreferences +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.sdk.model.api.CodecProfile +import org.jellyfin.sdk.model.api.ContainerProfile +import org.jellyfin.sdk.model.api.DeviceProfile +import org.jellyfin.sdk.model.api.DirectPlayProfile +import org.jellyfin.sdk.model.api.DlnaProfileType +import org.jellyfin.sdk.model.api.MediaStreamProtocol +import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod +import org.jellyfin.sdk.model.api.SubtitleProfile +import org.jellyfin.sdk.model.api.TranscodingProfile + +class DeviceProfileBuilder( + private val appPreferences: AppPreferences, +) { + private val supportedVideoCodecs: Array> + private val supportedAudioCodecs: Array> + + private val transcodingProfiles: List + + init { + require( + SUPPORTED_CONTAINER_FORMATS.size == AVAILABLE_VIDEO_CODECS.size && SUPPORTED_CONTAINER_FORMATS.size == AVAILABLE_AUDIO_CODECS.size, + ) + + // Load Android-supported codecs + val videoCodecs: MutableMap = HashMap() + val audioCodecs: MutableMap = HashMap() + val androidCodecs = MediaCodecList(MediaCodecList.REGULAR_CODECS) + for (codecInfo in androidCodecs.codecInfos) { + if (codecInfo.isEncoder) continue + + for (mimeType in codecInfo.supportedTypes) { + val codec = DeviceCodec.from(codecInfo.getCapabilitiesForType(mimeType)) ?: continue + val name = codec.name + when (codec) { + is DeviceCodec.Video -> { + if (videoCodecs.containsKey(name)) { + videoCodecs[name] = videoCodecs[name]!!.mergeCodec(codec) + } else { + videoCodecs[name] = codec + } + } + is DeviceCodec.Audio -> { + if (audioCodecs.containsKey(mimeType)) { + audioCodecs[name] = audioCodecs[name]!!.mergeCodec(codec) + } else { + audioCodecs[name] = codec + } + } + } + } + } + + // Build map of supported codecs from device support and hardcoded data + supportedVideoCodecs = Array(AVAILABLE_VIDEO_CODECS.size) { i -> + AVAILABLE_VIDEO_CODECS[i].filter { codec -> + videoCodecs.containsKey(codec) + }.toTypedArray() + } + supportedAudioCodecs = Array(AVAILABLE_AUDIO_CODECS.size) { i -> + AVAILABLE_AUDIO_CODECS[i].filter { codec -> + audioCodecs.containsKey(codec) || codec in FORCED_AUDIO_CODECS + }.toTypedArray() + } + + transcodingProfiles = listOf( + TranscodingProfile( + type = DlnaProfileType.VIDEO, + container = "ts", + videoCodec = "h264", + audioCodec = "mp1,mp2,mp3,aac,ac3,eac3,dts,mlp,truehd", + protocol = MediaStreamProtocol.HLS, + conditions = emptyList(), + ), + TranscodingProfile( + type = DlnaProfileType.VIDEO, + container = "mkv", + videoCodec = "h264", + audioCodec = AVAILABLE_AUDIO_CODECS[SUPPORTED_CONTAINER_FORMATS.indexOf("mkv")].joinToString(","), + protocol = MediaStreamProtocol.HLS, + conditions = emptyList(), + ), + TranscodingProfile( + type = DlnaProfileType.AUDIO, + container = "mp3", + videoCodec = "", + audioCodec = "mp3", + protocol = MediaStreamProtocol.HTTP, + conditions = emptyList(), + ), + ) + } + + fun getDeviceProfile(): DeviceProfile { + val containerProfiles = ArrayList() + val directPlayProfiles = ArrayList() + val codecProfiles = ArrayList() + + for (i in SUPPORTED_CONTAINER_FORMATS.indices) { + val container = SUPPORTED_CONTAINER_FORMATS[i] + if (supportedVideoCodecs[i].isNotEmpty()) { + containerProfiles.add( + ContainerProfile(type = DlnaProfileType.VIDEO, container = container, conditions = emptyList()), + ) + directPlayProfiles.add( + DirectPlayProfile( + type = DlnaProfileType.VIDEO, + container = SUPPORTED_CONTAINER_FORMATS[i], + videoCodec = supportedVideoCodecs[i].joinToString(","), + audioCodec = supportedAudioCodecs[i].joinToString(","), + ), + ) + } + if (supportedAudioCodecs[i].isNotEmpty()) { + containerProfiles.add( + ContainerProfile(type = DlnaProfileType.AUDIO, container = container, conditions = emptyList()), + ) + directPlayProfiles.add( + DirectPlayProfile( + type = DlnaProfileType.AUDIO, + container = SUPPORTED_CONTAINER_FORMATS[i], + audioCodec = supportedAudioCodecs[i].joinToString(","), + ), + ) + } + } + + val subtitleProfiles = when { + appPreferences.exoPlayerDirectPlayAss -> { + getSubtitleProfiles(EXO_EMBEDDED_SUBTITLES + SUBTITLES_SSA, EXO_EXTERNAL_SUBTITLES + SUBTITLES_SSA) + } + else -> getSubtitleProfiles(EXO_EMBEDDED_SUBTITLES, EXO_EXTERNAL_SUBTITLES) + } + + return DeviceProfile( + name = Constants.APP_INFO_NAME, + directPlayProfiles = directPlayProfiles, + transcodingProfiles = transcodingProfiles, + containerProfiles = containerProfiles, + codecProfiles = codecProfiles, + subtitleProfiles = subtitleProfiles, + maxStreamingBitrate = MAX_STREAMING_BITRATE, + maxStaticBitrate = MAX_STATIC_BITRATE, + musicStreamingTranscodingBitrate = MAX_MUSIC_TRANSCODING_BITRATE, + ) + } + + private fun getSubtitleProfiles(embedded: Array, external: Array): List = ArrayList().apply { + for (format in embedded) { + add(SubtitleProfile(format = format, method = SubtitleDeliveryMethod.EMBED)) + } + for (format in external) { + add(SubtitleProfile(format = format, method = SubtitleDeliveryMethod.EXTERNAL)) + } + } + + fun getExternalPlayerProfile(): DeviceProfile = DeviceProfile( + name = EXTERNAL_PLAYER_PROFILE_NAME, + directPlayProfiles = listOf( + DirectPlayProfile(type = DlnaProfileType.VIDEO, container = ""), + DirectPlayProfile(type = DlnaProfileType.AUDIO, container = ""), + ), + transcodingProfiles = emptyList(), + containerProfiles = emptyList(), + codecProfiles = emptyList(), + subtitleProfiles = buildList { + EXTERNAL_PLAYER_SUBTITLES.mapTo(this) { format -> + SubtitleProfile(format = format, method = SubtitleDeliveryMethod.EMBED) + } + EXTERNAL_PLAYER_SUBTITLES.mapTo(this) { format -> + SubtitleProfile(format = format, method = SubtitleDeliveryMethod.EXTERNAL) + } + }, + maxStreamingBitrate = Int.MAX_VALUE, + maxStaticBitrate = Int.MAX_VALUE, + musicStreamingTranscodingBitrate = Int.MAX_VALUE, + ) + + companion object { + private const val EXTERNAL_PLAYER_PROFILE_NAME = Constants.APP_INFO_NAME + " External Player" + + /** + * List of container formats supported by ExoPlayer + * + * IMPORTANT: Don't change without updating [AVAILABLE_VIDEO_CODECS] and [AVAILABLE_AUDIO_CODECS] + */ + private val SUPPORTED_CONTAINER_FORMATS = arrayOf( + "mp4", "fmp4", "webm", "mkv", "mp3", "ogg", "wav", "mpegts", "flv", "aac", "flac", "3gp", + ) + + /** + * IMPORTANT: Must have same length as [SUPPORTED_CONTAINER_FORMATS], + * as it maps the codecs to the containers with the same index! + */ + private val AVAILABLE_VIDEO_CODECS = arrayOf( + // mp4 + arrayOf("mpeg1video", "mpeg2video", "h263", "mpeg4", "h264", "hevc", "av1", "vp9"), + // fmp4 + arrayOf("mpeg1video", "mpeg2video", "h263", "mpeg4", "h264", "hevc", "av1", "vp9"), + // webm + arrayOf("vp8", "vp9", "av1"), + // mkv + arrayOf("mpeg1video", "mpeg2video", "h263", "mpeg4", "h264", "hevc", "av1", "vp8", "vp9", "av1"), + // mp3 + emptyArray(), + // ogg + emptyArray(), + // wav + emptyArray(), + // mpegts + arrayOf("mpeg1video", "mpeg2video", "mpeg4", "h264", "hevc"), + // flv + arrayOf("mpeg4", "h264"), + // aac + emptyArray(), + // flac + emptyArray(), + // 3gp + arrayOf("h263", "mpeg4", "h264", "hevc"), + ) + + /** + * List of PCM codecs supported by ExoPlayer by default + */ + private val PCM_CODECS = arrayOf( + "pcm_s8", + "pcm_s16be", + "pcm_s16le", + "pcm_s24le", + "pcm_s32le", + "pcm_f32le", + "pcm_alaw", + "pcm_mulaw", + ) + + /** + * IMPORTANT: Must have same length as [SUPPORTED_CONTAINER_FORMATS], + * as it maps the codecs to the containers with the same index! + */ + private val AVAILABLE_AUDIO_CODECS = arrayOf( + // mp4 + arrayOf("mp1", "mp2", "mp3", "aac", "alac", "ac3"), + // fmp4 + arrayOf("mp3", "aac", "ac3", "eac3"), + // webm + arrayOf("vorbis", "opus"), + // mkv + arrayOf(*PCM_CODECS, "mp1", "mp2", "mp3", "aac", "vorbis", "opus", "flac", "alac", "ac3", "eac3", "dts", "mlp", "truehd"), + // mp3 + arrayOf("mp3"), + // ogg + arrayOf("vorbis", "opus", "flac"), + // wav + PCM_CODECS, + // mpegts + arrayOf(*PCM_CODECS, "mp1", "mp2", "mp3", "aac", "ac3", "eac3", "dts", "mlp", "truehd"), + // flv + arrayOf("mp3", "aac"), + // aac + arrayOf("aac"), + // flac + arrayOf("flac"), + // 3gp + arrayOf("3gpp", "aac", "flac"), + ) + + /** + * List of audio codecs that will be added to the device profile regardless of [MediaCodecList] advertising them. + * This is especially useful for codecs supported by decoders integrated to ExoPlayer or added through an extension. + */ + private val FORCED_AUDIO_CODECS = arrayOf(*PCM_CODECS, "alac", "aac", "ac3", "eac3", "dts", "mlp", "truehd") + + private val EXO_EMBEDDED_SUBTITLES = arrayOf("dvbsub", "pgssub", "srt", "subrip", "ttml") + private val EXO_EXTERNAL_SUBTITLES = arrayOf("srt", "subrip", "ttml", "vtt", "webvtt") + private val SUBTITLES_SSA = arrayOf("ssa", "ass") + private val EXTERNAL_PLAYER_SUBTITLES = arrayOf("ass", "dvbsub", "pgssub", "srt", "srt", "ssa", "subrip", "subrip", "ttml", "ttml", "vtt", "webvtt") + + /** + * Taken from Jellyfin Web: + * https://github.com/jellyfin/jellyfin-web/blob/de690740f03c0568ba3061c4c586bd78b375d882/src/scripts/browserDeviceProfile.js#L276 + */ + private const val MAX_STREAMING_BITRATE = 120000000 + + /** + * Taken from Jellyfin Web: + * https://github.com/jellyfin/jellyfin-web/blob/de690740f03c0568ba3061c4c586bd78b375d882/src/scripts/browserDeviceProfile.js#L372 + */ + private const val MAX_STATIC_BITRATE = 100000000 + + /** + * Taken from Jellyfin Web: + * https://github.com/jellyfin/jellyfin-web/blob/de690740f03c0568ba3061c4c586bd78b375d882/src/scripts/browserDeviceProfile.js#L373 + */ + private const val MAX_MUSIC_TRANSCODING_BITRATE = 384000 + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayOptions.kt b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayOptions.kt new file mode 100644 index 0000000..70ace19 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayOptions.kt @@ -0,0 +1,42 @@ +package org.jellyfin.mobile.player.interaction + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.jellyfin.mobile.utils.extensions.size +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import org.json.JSONException +import org.json.JSONObject +import timber.log.Timber +import java.util.UUID + +@Parcelize +data class PlayOptions( + val ids: List, + val mediaSourceId: String?, + val startIndex: Int, + val startPositionTicks: Long?, + val audioStreamIndex: Int?, + val subtitleStreamIndex: Int?, +) : Parcelable { + companion object { + fun fromJson(json: JSONObject): PlayOptions? = try { + PlayOptions( + ids = json.optJSONArray("ids")?.let { array -> + ArrayList().apply { + for (i in 0 until array.size) { + array.getString(i).toUUIDOrNull()?.let(this::add) + } + } + } ?: emptyList(), + mediaSourceId = json.optString("mediaSourceId"), + startIndex = json.optInt("startIndex"), + startPositionTicks = json.optLong("startPositionTicks").takeIf { it > 0 }, + audioStreamIndex = json.optString("audioStreamIndex").toIntOrNull(), + subtitleStreamIndex = json.optString("subtitleStreamIndex").toIntOrNull(), + ) + } catch (e: JSONException) { + Timber.e(e, "Failed to parse playback options: %s", json) + null + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerEvent.kt b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerEvent.kt new file mode 100644 index 0000000..4c8ed60 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerEvent.kt @@ -0,0 +1,10 @@ +package org.jellyfin.mobile.player.interaction + +sealed class PlayerEvent { + object Pause : PlayerEvent() + object Resume : PlayerEvent() + object Stop : PlayerEvent() + object Destroy : PlayerEvent() + data class Seek(val ms: Long) : PlayerEvent() + data class SetVolume(val volume: Int) : PlayerEvent() +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerLifecycleObserver.kt b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerLifecycleObserver.kt new file mode 100644 index 0000000..200feca --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerLifecycleObserver.kt @@ -0,0 +1,18 @@ +package org.jellyfin.mobile.player.interaction + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import org.jellyfin.mobile.player.PlayerViewModel + +class PlayerLifecycleObserver(private val viewModel: PlayerViewModel) : DefaultLifecycleObserver { + + override fun onCreate(owner: LifecycleOwner) { + viewModel.setupPlayer() + } + + override fun onStop(owner: LifecycleOwner) { + if (!viewModel.notificationHelper.allowBackgroundAudio) { + viewModel.pause() + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerMediaSessionCallback.kt b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerMediaSessionCallback.kt new file mode 100644 index 0000000..81c077a --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerMediaSessionCallback.kt @@ -0,0 +1,40 @@ +package org.jellyfin.mobile.player.interaction + +import android.annotation.SuppressLint +import android.media.session.MediaSession +import org.jellyfin.mobile.player.PlayerViewModel + +@SuppressLint("MissingOnPlayFromSearch") +class PlayerMediaSessionCallback(private val viewModel: PlayerViewModel) : MediaSession.Callback() { + override fun onPlay() { + viewModel.play() + } + + override fun onPause() { + viewModel.pause() + } + + override fun onSeekTo(pos: Long) { + viewModel.playerOrNull?.seekTo(pos) + } + + override fun onRewind() { + viewModel.rewind() + } + + override fun onFastForward() { + viewModel.fastForward() + } + + override fun onSkipToPrevious() { + viewModel.skipToPrevious() + } + + override fun onSkipToNext() { + viewModel.skipToNext() + } + + override fun onStop() { + viewModel.stop() + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationAction.kt b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationAction.kt new file mode 100644 index 0000000..98e0d92 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationAction.kt @@ -0,0 +1,48 @@ +package org.jellyfin.mobile.player.interaction + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import org.jellyfin.mobile.R +import org.jellyfin.mobile.utils.Constants + +enum class PlayerNotificationAction( + val action: String, + @DrawableRes val icon: Int, + @StringRes val label: Int, +) { + PLAY( + Constants.ACTION_PLAY, + R.drawable.ic_play_black_42dp, + R.string.notification_action_play, + ), + PAUSE( + Constants.ACTION_PAUSE, + R.drawable.ic_pause_black_42dp, + R.string.notification_action_pause, + ), + REWIND( + Constants.ACTION_REWIND, + R.drawable.ic_rewind_black_32dp, + R.string.notification_action_rewind, + ), + FAST_FORWARD( + Constants.ACTION_FAST_FORWARD, + R.drawable.ic_fast_forward_black_32dp, + R.string.notification_action_fast_forward, + ), + PREVIOUS( + Constants.ACTION_PREVIOUS, + R.drawable.ic_skip_previous_black_32dp, + R.string.notification_action_previous, + ), + NEXT( + Constants.ACTION_NEXT, + R.drawable.ic_skip_next_black_32dp, + R.string.notification_action_next, + ), + STOP( + Constants.ACTION_STOP, + 0, + R.string.notification_action_stop, + ), +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt new file mode 100644 index 0000000..646bb8f --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt @@ -0,0 +1,198 @@ +package org.jellyfin.mobile.player.interaction + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.Bitmap +import android.media.MediaMetadata +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.core.graphics.drawable.toBitmap +import androidx.lifecycle.viewModelScope +import coil.ImageLoader +import coil.request.ImageRequest +import com.google.android.exoplayer2.Player +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.mobile.BuildConfig +import org.jellyfin.mobile.MainActivity +import org.jellyfin.mobile.R +import org.jellyfin.mobile.app.AppPreferences +import org.jellyfin.mobile.player.PlayerViewModel +import org.jellyfin.mobile.player.source.JellyfinMediaSource +import org.jellyfin.mobile.utils.AndroidVersion +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.utils.Constants.VIDEO_PLAYER_NOTIFICATION_ID +import org.jellyfin.mobile.utils.createMediaNotificationChannel +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.api.ImageType +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.component.inject +import java.util.concurrent.atomic.AtomicBoolean + +class PlayerNotificationHelper(private val viewModel: PlayerViewModel) : KoinComponent { + private val context: Context = viewModel.getApplication() + private val appPreferences: AppPreferences by inject() + private val notificationManager: NotificationManager? by lazy { context.getSystemService() } + private val imageApi: ImageApi = get().imageApi + private val imageLoader: ImageLoader by inject() + private val receiverRegistered = AtomicBoolean(false) + + val allowBackgroundAudio: Boolean + get() = appPreferences.exoPlayerAllowBackgroundAudio + + private val notificationActionReceiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + Constants.ACTION_PLAY -> viewModel.play() + Constants.ACTION_PAUSE -> viewModel.pause() + Constants.ACTION_REWIND -> viewModel.rewind() + Constants.ACTION_FAST_FORWARD -> viewModel.fastForward() + Constants.ACTION_PREVIOUS -> viewModel.skipToPrevious() + Constants.ACTION_NEXT -> viewModel.skipToNext() + Constants.ACTION_STOP -> viewModel.stop() + } + } + } + + @Suppress("DEPRECATION", "LongMethod", "CyclomaticComplexMethod") + fun postNotification() { + val nm = notificationManager ?: return + val player = viewModel.playerOrNull ?: return + val currentMediaSource = viewModel.queueManager.currentMediaSourceOrNull ?: return + val hasPrevious = viewModel.queueManager.hasPrevious() + val hasNext = viewModel.queueManager.hasNext() + val playbackState = player.playbackState + if (playbackState != Player.STATE_READY && playbackState != Player.STATE_BUFFERING) return + + // Create notification channel + context.createMediaNotificationChannel(nm) + + viewModel.viewModelScope.launch { + val mediaIcon: Bitmap? = withContext(Dispatchers.IO) { + loadImage(currentMediaSource) + } + + val style = Notification.MediaStyle().apply { + setMediaSession(viewModel.mediaSession.sessionToken) + setShowActionsInCompactView(0, 1, 2) + } + + val notification = Notification.Builder(context).apply { + if (AndroidVersion.isAtLeastO) { + // Set notification channel on Android O and above + setChannelId(Constants.MEDIA_NOTIFICATION_CHANNEL_ID) + setColorized(true) + } else { + setPriority(Notification.PRIORITY_LOW) + } + setSmallIcon(R.drawable.ic_notification) + mediaIcon?.let(::setLargeIcon) + setContentTitle(currentMediaSource.name) + currentMediaSource.item?.artists?.joinToString()?.let(::setContentText) + setStyle(style) + setVisibility(Notification.VISIBILITY_PUBLIC) + when { + hasPrevious -> addAction(generateAction(PlayerNotificationAction.PREVIOUS)) + else -> addAction(generateAction(PlayerNotificationAction.REWIND)) + } + val playbackAction = when { + !player.playWhenReady -> PlayerNotificationAction.PLAY + else -> PlayerNotificationAction.PAUSE + } + addAction(generateAction(playbackAction)) + when { + hasNext -> addAction(generateAction(PlayerNotificationAction.NEXT)) + else -> addAction(generateAction(PlayerNotificationAction.FAST_FORWARD)) + } + setContentIntent(buildContentIntent()) + setDeleteIntent(buildDeleteIntent()) + + // prevents the notification from being dismissed while playback is ongoing + setOngoing(player.isPlaying) + }.build() + + nm.notify(VIDEO_PLAYER_NOTIFICATION_ID, notification) + + mediaIcon?.let { + viewModel.mediaSession.controller.metadata?.let { + if (!it.containsKey(MediaMetadata.METADATA_KEY_ART)) { + viewModel.mediaSession.setMetadata( + MediaMetadata.Builder(it) + .putBitmap(MediaMetadata.METADATA_KEY_ART, mediaIcon) + .build(), + ) + } + } + } + } + + if (receiverRegistered.compareAndSet(false, true)) { + val filter = IntentFilter() + for (notificationAction in PlayerNotificationAction.values()) { + filter.addAction(notificationAction.action) + } + ContextCompat.registerReceiver( + context, + notificationActionReceiver, + filter, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + } + } + + fun dismissNotification() { + notificationManager?.cancel(VIDEO_PLAYER_NOTIFICATION_ID) + if (receiverRegistered.compareAndSet(true, false)) { + context.unregisterReceiver(notificationActionReceiver) + } + } + + private suspend fun loadImage(mediaSource: JellyfinMediaSource): Bitmap? { + val size = context.resources.getDimensionPixelSize(R.dimen.media_notification_height) + + val imageUrl = imageApi.getItemImageUrl( + itemId = mediaSource.itemId, + imageType = ImageType.PRIMARY, + maxWidth = size, + maxHeight = size, + ) + val imageRequest = ImageRequest.Builder(context).data(imageUrl).build() + return imageLoader.execute(imageRequest).drawable?.toBitmap() + } + + private fun generateAction(playerNotificationAction: PlayerNotificationAction): Notification.Action { + val intent = Intent(playerNotificationAction.action).apply { + `package` = BuildConfig.APPLICATION_ID + } + val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, Constants.PENDING_INTENT_FLAGS) + @Suppress("DEPRECATION") + return Notification.Action.Builder( + playerNotificationAction.icon, + context.getString(playerNotificationAction.label), + pendingIntent, + ).build() + } + + private fun buildContentIntent(): PendingIntent { + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT + } + return PendingIntent.getActivity(context, 0, intent, Constants.PENDING_INTENT_FLAGS) + } + + private fun buildDeleteIntent(): PendingIntent { + val intent = Intent(Constants.ACTION_STOP).apply { + `package` = BuildConfig.APPLICATION_ID + } + return PendingIntent.getBroadcast(context, 0, intent, Constants.PENDING_INTENT_FLAGS) + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/qualityoptions/QualityOption.kt b/app/src/main/java/org/jellyfin/mobile/player/qualityoptions/QualityOption.kt new file mode 100644 index 0000000..77fbd3a --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/qualityoptions/QualityOption.kt @@ -0,0 +1,6 @@ +package org.jellyfin.mobile.player.qualityoptions + +data class QualityOption( + val maxHeight: Int, + val bitrate: Int, +) diff --git a/app/src/main/java/org/jellyfin/mobile/player/qualityoptions/QualityOptionsProvider.kt b/app/src/main/java/org/jellyfin/mobile/player/qualityoptions/QualityOptionsProvider.kt new file mode 100644 index 0000000..4f3d9cb --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/qualityoptions/QualityOptionsProvider.kt @@ -0,0 +1,46 @@ +package org.jellyfin.mobile.player.qualityoptions + +import android.util.Rational +import org.jellyfin.mobile.utils.Constants + +class QualityOptionsProvider { + + private val defaultQualityOptions = listOf( + QualityOption(maxHeight = 2160, bitrate = 120000000), + QualityOption(maxHeight = 2160, bitrate = 80000000), + QualityOption(maxHeight = 1080, bitrate = 60000000), + QualityOption(maxHeight = 1080, bitrate = 40000000), + QualityOption(maxHeight = 1080, bitrate = 20000000), + QualityOption(maxHeight = 1080, bitrate = 15000000), + QualityOption(maxHeight = 1080, bitrate = 10000000), + QualityOption(maxHeight = 720, bitrate = 8000000), + QualityOption(maxHeight = 720, bitrate = 6000000), + QualityOption(maxHeight = 720, bitrate = 4000000), + QualityOption(maxHeight = 480, bitrate = 3000000), + QualityOption(maxHeight = 480, bitrate = 1500000), + QualityOption(maxHeight = 480, bitrate = 720000), + QualityOption(maxHeight = 360, bitrate = 420000), + QualityOption(maxHeight = 0, bitrate = 0), // auto + ) + + @Suppress("MagicNumber") + fun getApplicableQualityOptions(videoWidth: Int, videoHeight: Int): List { + // If the aspect ratio is less than 16/9, set the width as if it were pillarboxed + // i.e. 4:3 1440x1080 -> 1920x1080 + val maxAllowedWidth = when { + Rational(videoWidth, videoHeight) < Constants.ASPECT_RATIO_16_9 -> videoHeight * 16 / 9 + else -> videoWidth + } + + val maxAllowedHeight = when { + maxAllowedWidth >= 3800 -> 2160 + // Some 1080p videos are apparently reported as 1912 + maxAllowedWidth >= 1900 -> 1080 + maxAllowedWidth >= 1260 -> 720 + maxAllowedWidth >= 620 -> 480 + else -> 360 + } + + return defaultQualityOptions.takeLastWhile { option -> option.maxHeight <= maxAllowedHeight } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt b/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt new file mode 100644 index 0000000..a77d73e --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt @@ -0,0 +1,330 @@ +package org.jellyfin.mobile.player.queue + +import android.net.Uri +import androidx.annotation.CheckResult +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.source.MediaSource +import com.google.android.exoplayer2.source.MergingMediaSource +import com.google.android.exoplayer2.source.ProgressiveMediaSource +import com.google.android.exoplayer2.source.SingleSampleMediaSource +import com.google.android.exoplayer2.source.hls.HlsMediaSource +import org.jellyfin.mobile.player.PlayerException +import org.jellyfin.mobile.player.PlayerViewModel +import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder +import org.jellyfin.mobile.player.interaction.PlayOptions +import org.jellyfin.mobile.player.source.ExternalSubtitleStream +import org.jellyfin.mobile.player.source.JellyfinMediaSource +import org.jellyfin.mobile.player.source.MediaSourceResolver +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.videosApi +import org.jellyfin.sdk.api.operations.VideosApi +import org.jellyfin.sdk.model.api.MediaProtocol +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.PlayMethod +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.component.inject +import java.util.UUID + +class QueueManager( + private val viewModel: PlayerViewModel, +) : KoinComponent { + private val apiClient: ApiClient = get() + private val videosApi: VideosApi = apiClient.videosApi + private val mediaSourceResolver: MediaSourceResolver by inject() + private val deviceProfileBuilder: DeviceProfileBuilder by inject() + private val deviceProfile = deviceProfileBuilder.getDeviceProfile() + + private var currentQueue: List = emptyList() + private var currentQueueIndex: Int = 0 + + private val _currentMediaSource: MutableLiveData = MutableLiveData() + val currentMediaSource: LiveData + get() = _currentMediaSource + + inline val currentMediaSourceOrNull: JellyfinMediaSource? + get() = currentMediaSource.value + + /** + * Handle initial playback options from fragment. + * Start of a playback session that can contain one or multiple played videos. + * + * @return an error of type [PlayerException] or null on success. + */ + suspend fun initializePlaybackQueue(playOptions: PlayOptions): PlayerException? { + currentQueue = playOptions.ids + currentQueueIndex = playOptions.startIndex + + val itemId = when { + currentQueue.isNotEmpty() -> currentQueue[currentQueueIndex] + else -> playOptions.mediaSourceId?.toUUIDOrNull() + } ?: return PlayerException.InvalidPlayOptions() + + startPlayback( + itemId = itemId, + mediaSourceId = playOptions.mediaSourceId, + maxStreamingBitrate = null, + startTimeTicks = playOptions.startPositionTicks, + audioStreamIndex = playOptions.audioStreamIndex, + subtitleStreamIndex = playOptions.subtitleStreamIndex, + playWhenReady = true, + ) + + return null + } + + /** + * Play a specific media item specified by [itemId] and [mediaSourceId]. + * + * @return an error of type [PlayerException] or null on success. + */ + private suspend fun startPlayback( + itemId: UUID, + mediaSourceId: String?, + maxStreamingBitrate: Int?, + startTimeTicks: Long? = null, + audioStreamIndex: Int? = null, + subtitleStreamIndex: Int? = null, + playWhenReady: Boolean = true, + ): PlayerException? { + mediaSourceResolver.resolveMediaSource( + itemId = itemId, + mediaSourceId = mediaSourceId, + deviceProfile = deviceProfile, + maxStreamingBitrate = maxStreamingBitrate, + startTimeTicks = startTimeTicks, + audioStreamIndex = audioStreamIndex, + subtitleStreamIndex = subtitleStreamIndex, + ).onSuccess { jellyfinMediaSource -> + // Ensure transcoding of the current element is stopped + currentMediaSourceOrNull?.let { oldMediaSource -> + viewModel.stopTranscoding(oldMediaSource) + } + + _currentMediaSource.value = jellyfinMediaSource + + // Load new media source + viewModel.load(jellyfinMediaSource, prepareStreams(jellyfinMediaSource), playWhenReady) + }.onFailure { error -> + // Should always be of this type, other errors are silently dropped + return error as? PlayerException + } + return null + } + + /** + * Reinitialize current media source without changing settings + */ + fun tryRestartPlayback() { + val currentMediaSource = currentMediaSourceOrNull ?: return + + viewModel.load(currentMediaSource, prepareStreams(currentMediaSource), playWhenReady = true) + } + + /** + * Change the maximum bitrate to the specified value. + */ + suspend fun changeBitrate(bitrate: Int?): Boolean { + val currentMediaSource = currentMediaSourceOrNull ?: return false + + // Bitrate didn't change, ignore + if (currentMediaSource.maxStreamingBitrate == bitrate) return true + + val currentPlayState = viewModel.getStateAndPause() ?: return false + + return startPlayback( + itemId = currentMediaSource.itemId, + mediaSourceId = currentMediaSource.id, + maxStreamingBitrate = bitrate, + startTimeTicks = currentPlayState.position * Constants.TICKS_PER_MILLISECOND, + audioStreamIndex = currentMediaSource.selectedAudioStreamIndex, + subtitleStreamIndex = currentMediaSource.selectedSubtitleStreamIndex, + playWhenReady = currentPlayState.playWhenReady, + ) == null + } + + fun hasPrevious(): Boolean = currentQueue.isNotEmpty() && currentQueueIndex > 0 + + fun hasNext(): Boolean = currentQueue.isNotEmpty() && currentQueueIndex < currentQueue.lastIndex + + suspend fun previous(): Boolean { + if (!hasPrevious()) return false + + val currentMediaSource = currentMediaSourceOrNull ?: return false + + startPlayback( + itemId = currentQueue[--currentQueueIndex], + mediaSourceId = null, + maxStreamingBitrate = currentMediaSource.maxStreamingBitrate, + ) + return true + } + + suspend fun next(): Boolean { + if (!hasNext()) return false + + val currentMediaSource = currentMediaSourceOrNull ?: return false + + startPlayback( + itemId = currentQueue[++currentQueueIndex], + mediaSourceId = null, + maxStreamingBitrate = currentMediaSource.maxStreamingBitrate, + ) + return true + } + + /** + * Builds the [MediaSource] to be played by ExoPlayer. + * + * @param source The [JellyfinMediaSource] object containing all necessary info about the item to be played. + * @return A [MediaSource]. This can be the media stream of the correct type for the playback method or + * a [MergingMediaSource] containing the mentioned media stream and all external subtitle streams. + */ + @CheckResult + private fun prepareStreams(source: JellyfinMediaSource): MediaSource { + val videoSource = createVideoMediaSource(source) + val subtitleSources = createExternalSubtitleMediaSources(source) + return when { + subtitleSources.isNotEmpty() -> MergingMediaSource(videoSource, *subtitleSources) + else -> videoSource + } + } + + /** + * Builds the [MediaSource] for the main media stream (video/audio/embedded subs). + * + * @param source The [JellyfinMediaSource] object containing all necessary info about the item to be played. + * @return A [MediaSource]. The type of MediaSource depends on the playback method/protocol. + */ + @CheckResult + private fun createVideoMediaSource(source: JellyfinMediaSource): MediaSource { + val sourceInfo = source.sourceInfo + val (url, factory) = when (source.playMethod) { + PlayMethod.DIRECT_PLAY -> { + when (sourceInfo.protocol) { + MediaProtocol.FILE -> { + val url = videosApi.getVideoStreamUrl( + itemId = source.itemId, + static = true, + playSessionId = source.playSessionId, + mediaSourceId = source.id, + deviceId = apiClient.deviceInfo.id, + ) + + url to get() + } + MediaProtocol.HTTP -> { + val url = requireNotNull(sourceInfo.path) + val factory = get().setAllowChunklessPreparation(true) + + url to factory + } + else -> throw IllegalArgumentException("Unsupported protocol ${sourceInfo.protocol}") + } + } + PlayMethod.DIRECT_STREAM -> { + val container = requireNotNull(sourceInfo.container) { "Missing direct stream container" } + val url = videosApi.getVideoStreamByContainerUrl( + itemId = source.itemId, + container = container, + playSessionId = source.playSessionId, + mediaSourceId = source.id, + deviceId = apiClient.deviceInfo.id, + ) + + url to get() + } + PlayMethod.TRANSCODE -> { + val transcodingPath = requireNotNull(sourceInfo.transcodingUrl) { "Missing transcode URL" } + val protocol = sourceInfo.transcodingSubProtocol + require(protocol == MediaStreamProtocol.HLS) { "Unsupported transcode protocol '$protocol'" } + val transcodingUrl = apiClient.createUrl(transcodingPath) + val factory = get().setAllowChunklessPreparation(true) + + transcodingUrl to factory + } + } + + val mediaItem = MediaItem.Builder() + .setMediaId(source.itemId.toString()) + .setUri(url) + .build() + + return factory.createMediaSource(mediaItem) + } + + /** + * Creates [MediaSource]s for all external subtitle streams in the [JellyfinMediaSource]. + * + * @param source The [JellyfinMediaSource] object containing all necessary info about the item to be played. + * @return The parsed MediaSources for the subtitles. + */ + @CheckResult + private fun createExternalSubtitleMediaSources( + source: JellyfinMediaSource, + ): Array { + val factory = get() + return source.externalSubtitleStreams.map { stream -> + val uri = Uri.parse(apiClient.createUrl(stream.deliveryUrl)) + val mediaItem = MediaItem.SubtitleConfiguration.Builder(uri).apply { + setId("${ExternalSubtitleStream.ID_PREFIX}${stream.index}") + setLabel(stream.displayTitle) + setMimeType(stream.mimeType) + setLanguage(stream.language) + }.build() + factory.createMediaSource(mediaItem, source.runTimeMs) + }.toTypedArray() + } + + /** + * Switch to the specified [audio stream][stream] and restart playback, for example while transcoding. + * + * @return true if playback was restarted with the new selection. + */ + suspend fun selectAudioStreamAndRestartPlayback(stream: MediaStream): Boolean { + require(stream.type == MediaStreamType.AUDIO) + val currentMediaSource = currentMediaSourceOrNull ?: return false + val currentPlayState = viewModel.getStateAndPause() ?: return false + + startPlayback( + itemId = currentMediaSource.itemId, + mediaSourceId = currentMediaSource.id, + maxStreamingBitrate = currentMediaSource.maxStreamingBitrate, + startTimeTicks = currentPlayState.position * Constants.TICKS_PER_MILLISECOND, + audioStreamIndex = stream.index, + subtitleStreamIndex = currentMediaSource.selectedSubtitleStreamIndex, + playWhenReady = currentPlayState.playWhenReady, + ) + return true + } + + /** + * Switch to the specified [subtitle stream][stream] and restart playback, + * for example because the selected subtitle has to be encoded into the video. + * + * @param stream The subtitle stream to select, or null to disable subtitles. + * @return true if playback was restarted with the new selection. + */ + suspend fun selectSubtitleStreamAndRestartPlayback(stream: MediaStream?): Boolean { + require(stream == null || stream.type == MediaStreamType.SUBTITLE) + val currentMediaSource = currentMediaSourceOrNull ?: return false + val currentPlayState = viewModel.getStateAndPause() ?: return false + + startPlayback( + itemId = currentMediaSource.itemId, + mediaSourceId = currentMediaSource.id, + maxStreamingBitrate = currentMediaSource.maxStreamingBitrate, + startTimeTicks = currentPlayState.position * Constants.TICKS_PER_MILLISECOND, + audioStreamIndex = currentMediaSource.selectedAudioStreamIndex, + subtitleStreamIndex = stream?.index ?: -1, // -1 disables subtitles, null would select the default subtitle + playWhenReady = currentPlayState.playWhenReady, + ) + return true + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/ExternalSubtitleStream.kt b/app/src/main/java/org/jellyfin/mobile/player/source/ExternalSubtitleStream.kt new file mode 100644 index 0000000..ea5e819 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/source/ExternalSubtitleStream.kt @@ -0,0 +1,13 @@ +package org.jellyfin.mobile.player.source + +data class ExternalSubtitleStream( + val index: Int, + val deliveryUrl: String, + val mimeType: String, + val displayTitle: String, + val language: String, +) { + companion object { + const val ID_PREFIX = "external:" + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt b/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt new file mode 100644 index 0000000..e309897 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSource.kt @@ -0,0 +1,166 @@ +package org.jellyfin.mobile.player.source + +import org.jellyfin.mobile.player.deviceprofile.CodecHelpers +import org.jellyfin.mobile.utils.Constants +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.PlayMethod +import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod +import java.util.UUID + +class JellyfinMediaSource( + val itemId: UUID, + val item: BaseItemDto?, + val sourceInfo: MediaSourceInfo, + val playSessionId: String, + val liveStreamId: String?, + val maxStreamingBitrate: Int?, + private var startTimeTicks: Long? = null, + audioStreamIndex: Int? = null, + subtitleStreamIndex: Int? = null, +) { + val id: String = requireNotNull(sourceInfo.id) { "Media source has no id" } + val name: String = item?.name ?: sourceInfo.name.orEmpty() + + val playMethod: PlayMethod = when { + sourceInfo.supportsDirectPlay -> PlayMethod.DIRECT_PLAY + sourceInfo.supportsDirectStream -> PlayMethod.DIRECT_STREAM + sourceInfo.supportsTranscoding -> PlayMethod.TRANSCODE + else -> throw IllegalArgumentException("No play method found for $name ($itemId)") + } + + var startTimeMs: Long + get() = (startTimeTicks ?: 0L) / Constants.TICKS_PER_MILLISECOND + set(value) { + startTimeTicks = value * Constants.TICKS_PER_MILLISECOND + } + val runTimeTicks: Long = sourceInfo.runTimeTicks ?: 0 + val runTimeMs: Long = runTimeTicks / Constants.TICKS_PER_MILLISECOND + + val mediaStreams: List = sourceInfo.mediaStreams.orEmpty() + val audioStreams: List + val subtitleStreams: List + val externalSubtitleStreams: List + + var selectedVideoStream: MediaStream? = null + private set + var selectedAudioStream: MediaStream? = null + private set + var selectedSubtitleStream: MediaStream? = null + private set + + val selectedAudioStreamIndex: Int? + get() = selectedAudioStream?.index + val selectedSubtitleStreamIndex: Int + // -1 disables subtitles, null would select the default subtitle + // If the default should be played, it would be explicitly set above + get() = selectedSubtitleStream?.index ?: -1 + + init { + // Classify MediaStreams + val audio = ArrayList() + val subtitles = ArrayList() + val externalSubtitles = ArrayList() + for (mediaStream in mediaStreams) { + when (mediaStream.type) { + MediaStreamType.VIDEO -> { + // Always select the first available video stream + if (selectedVideoStream == null) { + selectedVideoStream = mediaStream + } + } + MediaStreamType.AUDIO -> { + audio += mediaStream + if (mediaStream.index == (audioStreamIndex ?: sourceInfo.defaultAudioStreamIndex)) { + selectedAudioStream = mediaStream + } + } + MediaStreamType.SUBTITLE -> { + subtitles += mediaStream + if (mediaStream.index == (subtitleStreamIndex ?: sourceInfo.defaultSubtitleStreamIndex)) { + selectedSubtitleStream = mediaStream + } + + // External subtitles as specified by the deliveryMethod. + // It is set to external either for external subtitle files or when transcoding. + // In the latter case, subtitles are extracted from the source file by the server. + if (mediaStream.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL) { + val deliveryUrl = mediaStream.deliveryUrl + val mimeType = CodecHelpers.getSubtitleMimeType(mediaStream.codec) + if (deliveryUrl != null && mimeType != null) { + externalSubtitles += ExternalSubtitleStream( + index = mediaStream.index, + deliveryUrl = deliveryUrl, + mimeType = mimeType, + displayTitle = mediaStream.displayTitle.orEmpty(), + language = mediaStream.language ?: Constants.LANGUAGE_UNDEFINED, + ) + } + } + } + MediaStreamType.EMBEDDED_IMAGE, + MediaStreamType.DATA, + MediaStreamType.LYRIC, + -> Unit // ignore + } + } + + audioStreams = audio + subtitleStreams = subtitles + externalSubtitleStreams = externalSubtitles + } + + /** + * Select the specified [audio stream][stream] in the source. + * + * @param stream The stream to select. + * @return true if the stream was found and selected, false otherwise. + */ + fun selectAudioStream(stream: MediaStream): Boolean { + require(stream.type == MediaStreamType.AUDIO) + if (mediaStreams[stream.index] !== stream) { + return false + } + + selectedAudioStream = stream + return true + } + + /** + * Select the specified [subtitle stream][stream] in the source. + * + * @param stream The stream to select, or null to disable subtitles. + * @return true if the stream was found and selected, false otherwise. + */ + fun selectSubtitleStream(stream: MediaStream?): Boolean { + if (stream == null) { + selectedSubtitleStream = null + return true + } + + require(stream.type == MediaStreamType.SUBTITLE) + if (mediaStreams[stream.index] !== stream) { + return false + } + + selectedSubtitleStream = stream + return true + } + + /** + * Returns the index of the media stream within the embedded streams. + * Useful for handling track selection in ExoPlayer, where embedded streams are mapped first. + */ + fun getEmbeddedStreamIndex(mediaStream: MediaStream): Int { + var index = 0 + for (stream in mediaStreams) { + when { + stream === mediaStream -> return index + !stream.isExternal -> index++ + } + } + throw IllegalArgumentException("Invalid media stream") + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceResolver.kt b/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceResolver.kt new file mode 100644 index 0000000..c7a362b --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceResolver.kt @@ -0,0 +1,87 @@ +package org.jellyfin.mobile.player.source + +import org.jellyfin.mobile.player.PlayerException +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.ApiClientException +import org.jellyfin.sdk.api.client.extensions.mediaInfoApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.operations.MediaInfoApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +import org.jellyfin.sdk.model.api.DeviceProfile +import org.jellyfin.sdk.model.api.PlaybackInfoDto +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import java.util.UUID + +class MediaSourceResolver(private val apiClient: ApiClient) { + private val mediaInfoApi: MediaInfoApi = apiClient.mediaInfoApi + private val userLibraryApi: UserLibraryApi = apiClient.userLibraryApi + + @Suppress("ReturnCount") + suspend fun resolveMediaSource( + itemId: UUID, + mediaSourceId: String? = null, + deviceProfile: DeviceProfile? = null, + maxStreamingBitrate: Int? = null, + startTimeTicks: Long? = null, + audioStreamIndex: Int? = null, + subtitleStreamIndex: Int? = null, + autoOpenLiveStream: Boolean = true, + ): Result { + // Load media source info + val playSessionId: String + val mediaSourceInfo = try { + val response by mediaInfoApi.getPostedPlaybackInfo( + itemId = itemId, + data = PlaybackInfoDto( + // We need to remove the dashes so that the server can find the correct media source. + // And if we didn't pass the mediaSourceId, our stream indices would silently get ignored. + // https://github.com/jellyfin/jellyfin/blob/9a35fd673203cfaf0098138b2768750f4818b3ab/Jellyfin.Api/Helpers/MediaInfoHelper.cs#L196-L201 + mediaSourceId = mediaSourceId ?: itemId.toString().replace("-", ""), + deviceProfile = deviceProfile, + maxStreamingBitrate = maxStreamingBitrate, + startTimeTicks = startTimeTicks, + audioStreamIndex = audioStreamIndex, + subtitleStreamIndex = subtitleStreamIndex, + autoOpenLiveStream = autoOpenLiveStream, + ), + ) + + playSessionId = response.playSessionId ?: return Result.failure(PlayerException.UnsupportedContent()) + + response.mediaSources.let { sources -> + sources.find { source -> source.id?.toUUIDOrNull() == itemId } ?: sources.firstOrNull() + } ?: return Result.failure(PlayerException.UnsupportedContent()) + } catch (e: ApiClientException) { + Timber.e(e, "Failed to load media source $itemId") + return Result.failure(PlayerException.NetworkFailure(e)) + } + + // Load additional item info if possible + val item = try { + userLibraryApi.getItem(itemId).content + } catch (e: ApiClientException) { + Timber.e(e, "Failed to load item for media source $itemId") + null + } + + // Create JellyfinMediaSource + return try { + val source = JellyfinMediaSource( + itemId = itemId, + item = item, + sourceInfo = mediaSourceInfo, + playSessionId = playSessionId, + liveStreamId = mediaSourceInfo.liveStreamId, + maxStreamingBitrate = maxStreamingBitrate, + startTimeTicks = startTimeTicks, + audioStreamIndex = audioStreamIndex, + subtitleStreamIndex = subtitleStreamIndex, + ) + Result.success(source) + } catch (e: IllegalArgumentException) { + Timber.e(e, "Cannot create JellyfinMediaSource") + Result.failure(PlayerException.UnsupportedContent(e)) + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/DecoderType.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/DecoderType.kt new file mode 100644 index 0000000..6119169 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/DecoderType.kt @@ -0,0 +1,9 @@ +package org.jellyfin.mobile.player.ui + +/** + * Represents the type of decoder + */ +enum class DecoderType { + HARDWARE, + SOFTWARE, +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/DisplayPreferences.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/DisplayPreferences.kt new file mode 100644 index 0000000..9fe8027 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/DisplayPreferences.kt @@ -0,0 +1,8 @@ +package org.jellyfin.mobile.player.ui + +import org.jellyfin.mobile.utils.Constants + +data class DisplayPreferences( + val skipBackLength: Long = Constants.DEFAULT_SEEK_TIME_MS, + val skipForwardLength: Long = Constants.DEFAULT_SEEK_TIME_MS, +) diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayState.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayState.kt new file mode 100644 index 0000000..8b42252 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayState.kt @@ -0,0 +1,6 @@ +package org.jellyfin.mobile.player.ui + +data class PlayState( + val playWhenReady: Boolean, + val position: Long, +) diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt new file mode 100644 index 0000000..795efb1 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt @@ -0,0 +1,413 @@ +package org.jellyfin.mobile.player.ui + +import android.app.Activity +import android.app.PictureInPictureParams +import android.content.pm.ActivityInfo +import android.content.res.Configuration +import android.graphics.Rect +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.LayoutInflater +import android.view.OrientationEventListener +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE +import android.widget.ImageButton +import androidx.annotation.RequiresApi +import androidx.appcompat.widget.Toolbar +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.core.view.setPadding +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.ui.PlayerView +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.jellyfin.mobile.R +import org.jellyfin.mobile.app.AppPreferences +import org.jellyfin.mobile.databinding.ExoPlayerControlViewBinding +import org.jellyfin.mobile.databinding.FragmentPlayerBinding +import org.jellyfin.mobile.player.PlayerException +import org.jellyfin.mobile.player.PlayerViewModel +import org.jellyfin.mobile.player.interaction.PlayOptions +import org.jellyfin.mobile.utils.AndroidVersion +import org.jellyfin.mobile.utils.BackPressInterceptor +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.utils.Constants.DEFAULT_CONTROLS_TIMEOUT_MS +import org.jellyfin.mobile.utils.Constants.PIP_MAX_RATIONAL +import org.jellyfin.mobile.utils.Constants.PIP_MIN_RATIONAL +import org.jellyfin.mobile.utils.SmartOrientationListener +import org.jellyfin.mobile.utils.brightness +import org.jellyfin.mobile.utils.extensions.aspectRational +import org.jellyfin.mobile.utils.extensions.getParcelableCompat +import org.jellyfin.mobile.utils.extensions.isLandscape +import org.jellyfin.mobile.utils.extensions.keepScreenOn +import org.jellyfin.mobile.utils.toast +import org.jellyfin.sdk.model.api.MediaStream +import org.koin.android.ext.android.inject +import com.google.android.exoplayer2.ui.R as ExoplayerR + +class PlayerFragment : Fragment(), BackPressInterceptor { + private val appPreferences: AppPreferences by inject() + private val viewModel: PlayerViewModel by viewModels() + private var _playerBinding: FragmentPlayerBinding? = null + private val playerBinding: FragmentPlayerBinding get() = _playerBinding!! + private val playerView: PlayerView get() = playerBinding.playerView + private val playerOverlay: View get() = playerBinding.playerOverlay + private val loadingIndicator: View get() = playerBinding.loadingIndicator + private var _playerControlsBinding: ExoPlayerControlViewBinding? = null + private val playerControlsBinding: ExoPlayerControlViewBinding get() = _playerControlsBinding!! + private val playerControlsView: View get() = playerControlsBinding.root + private val toolbar: Toolbar get() = playerControlsBinding.toolbar + private val fullscreenSwitcher: ImageButton get() = playerControlsBinding.fullscreenSwitcher + private var playerMenus: PlayerMenus? = null + + private lateinit var playerFullscreenHelper: PlayerFullscreenHelper + lateinit var playerLockScreenHelper: PlayerLockScreenHelper + lateinit var playerGestureHelper: PlayerGestureHelper + + private val currentVideoStream: MediaStream? + get() = viewModel.mediaSourceOrNull?.selectedVideoStream + + /** + * Listener that watches the current device orientation. + * It makes sure that the orientation sensor can still be used (if enabled) + * after toggling the orientation through the fullscreen button. + * + * If the requestedOrientation was reset directly after setting it in the fullscreenSwitcher click handler, + * the orientation would get reverted before the user had any chance to rotate the device to the desired position. + */ + private val orientationListener: OrientationEventListener by lazy { SmartOrientationListener(requireActivity()) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val window = requireActivity().window + playerFullscreenHelper = PlayerFullscreenHelper(window) + + // Observe ViewModel + viewModel.player.observe(this) { player -> + playerView.player = player + if (player == null) parentFragmentManager.popBackStack() + } + viewModel.playerState.observe(this) { playerState -> + val isPlaying = viewModel.playerOrNull?.isPlaying == true + requireActivity().window.keepScreenOn = isPlaying + loadingIndicator.isVisible = playerState == Player.STATE_BUFFERING + } + viewModel.decoderType.observe(this) { type -> + playerMenus?.updatedSelectedDecoder(type) + } + viewModel.error.observe(this) { message -> + val safeMessage = message.ifEmpty { requireContext().getString(R.string.player_error_unspecific_exception) } + requireContext().toast(safeMessage) + } + viewModel.queueManager.currentMediaSource.observe(this) { mediaSource -> + if (mediaSource.selectedVideoStream?.isLandscape == false) { + // For portrait videos, immediately enable fullscreen + playerFullscreenHelper.enableFullscreen() + } else if (appPreferences.exoPlayerStartLandscapeVideoInLandscape) { + // Auto-switch to landscape for landscape videos if enabled + requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } + + // Update title and player menus + toolbar.title = mediaSource.name + playerMenus?.onQueueItemChanged(mediaSource, viewModel.queueManager.hasNext()) + } + + // Handle fragment arguments, extract playback options and start playback + lifecycleScope.launch { + val context = requireContext() + val playOptions = requireArguments().getParcelableCompat(Constants.EXTRA_MEDIA_PLAY_OPTIONS) + if (playOptions == null) { + context.toast(R.string.player_error_invalid_play_options) + return@launch + } + when (viewModel.queueManager.initializePlaybackQueue(playOptions)) { + is PlayerException.InvalidPlayOptions -> context.toast(R.string.player_error_invalid_play_options) + is PlayerException.NetworkFailure -> context.toast(R.string.player_error_network_failure) + is PlayerException.UnsupportedContent -> context.toast(R.string.player_error_unsupported_content) + null -> Unit // success + } + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _playerBinding = FragmentPlayerBinding.inflate(layoutInflater) + _playerControlsBinding = ExoPlayerControlViewBinding.bind(playerBinding.root.findViewById(R.id.player_controls)) + return playerBinding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Insets handling + ViewCompat.setOnApplyWindowInsetsListener(playerBinding.root) { _, insets -> + playerFullscreenHelper.onWindowInsetsChanged(insets) + + val systemInsets = when { + AndroidVersion.isAtLeastR -> insets.getInsetsIgnoringVisibility(WindowInsetsCompat.Type.systemBars()) + else -> insets.getInsets(WindowInsetsCompat.Type.systemBars()) + } + if (playerFullscreenHelper.isFullscreen) { + playerView.setPadding(0) + playerControlsView.updatePadding( + left = systemInsets.left, + top = systemInsets.top, + right = systemInsets.right, + bottom = systemInsets.bottom, + ) + } else { + playerView.updatePadding( + left = systemInsets.left, + top = systemInsets.top, + right = systemInsets.right, + bottom = systemInsets.bottom, + ) + playerControlsView.setPadding(0) // Padding is handled by PlayerView + } + playerOverlay.updatePadding( + left = systemInsets.left, + top = systemInsets.top, + right = systemInsets.right, + bottom = systemInsets.bottom, + ) + + // Update fullscreen switcher icon + val fullscreenDrawable = when { + playerFullscreenHelper.isFullscreen -> R.drawable.ic_fullscreen_exit_white_32dp + else -> R.drawable.ic_fullscreen_enter_white_32dp + } + fullscreenSwitcher.setImageResource(fullscreenDrawable) + + insets + } + + // Handle toolbar back button + toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } + + // Create playback menus + playerMenus = PlayerMenus(this, playerBinding, playerControlsBinding) + + // Set controller timeout + suppressControllerAutoHide(false) + + playerLockScreenHelper = PlayerLockScreenHelper(this, playerBinding, orientationListener) + playerGestureHelper = PlayerGestureHelper(this, playerBinding, playerLockScreenHelper) + + // Handle fullscreen switcher + fullscreenSwitcher.setOnClickListener { + toggleFullscreen() + } + } + + override fun onStart() { + super.onStart() + orientationListener.enable() + } + + override fun onResume() { + super.onResume() + + // When returning from another app, fullscreen mode for landscape orientation has to be set again + if (isLandscape()) { + playerFullscreenHelper.enableFullscreen() + } + } + + /** + * Handle current orientation and update fullscreen state and switcher icon + */ + private fun updateFullscreenState(configuration: Configuration) { + // Do not handle any orientation changes while being in Picture-in-Picture mode + if (AndroidVersion.isAtLeastN && requireActivity().isInPictureInPictureMode) { + return + } + + when { + isLandscape(configuration) -> { + // Landscape orientation is always fullscreen + playerFullscreenHelper.enableFullscreen() + } + currentVideoStream?.isLandscape != false -> { + // Disable fullscreen for landscape video in portrait orientation + playerFullscreenHelper.disableFullscreen() + } + } + } + + /** + * Toggle fullscreen. + * + * If playing a portrait video, this just hides the status and navigation bars. + * For landscape videos, additionally the screen gets rotated. + */ + private fun toggleFullscreen() { + val videoTrack = currentVideoStream + if (videoTrack == null || videoTrack.isLandscape) { + val current = resources.configuration.orientation + requireActivity().requestedOrientation = when (current) { + Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + // No need to call playerFullscreenHelper in this case, + // since the configuration change triggers updateFullscreenState, + // which does it for us. + } else { + playerFullscreenHelper.toggleFullscreen() + } + } + + /** + * If true, the player controls will show indefinitely + */ + fun suppressControllerAutoHide(suppress: Boolean) { + playerView.controllerShowTimeoutMs = if (suppress) -1 else DEFAULT_CONTROLS_TIMEOUT_MS + } + + fun isLandscape(configuration: Configuration = resources.configuration) = + configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + + fun onRewind() = viewModel.rewind() + + fun onFastForward() = viewModel.fastForward() + + /** + * @param callback called if track selection was successful and UI needs to be updated + */ + fun onAudioTrackSelected(index: Int, callback: TrackSelectionCallback): Job = lifecycleScope.launch { + if (viewModel.trackSelectionHelper.selectAudioTrack(index)) { + callback.onTrackSelected(true) + } + } + + /** + * @param callback called if track selection was successful and UI needs to be updated + */ + fun onSubtitleSelected(index: Int, callback: TrackSelectionCallback): Job = lifecycleScope.launch { + if (viewModel.trackSelectionHelper.selectSubtitleTrack(index)) { + callback.onTrackSelected(true) + } + } + + /** + * Toggle subtitles, selecting the first by [MediaStream.index] if there are multiple. + * + * @return true if subtitles are enabled now, false if not + */ + fun toggleSubtitles(callback: TrackSelectionCallback) = lifecycleScope.launch { + callback.onTrackSelected(viewModel.trackSelectionHelper.toggleSubtitles()) + } + + fun onBitrateChanged(bitrate: Int?, callback: TrackSelectionCallback) = lifecycleScope.launch { + callback.onTrackSelected(viewModel.changeBitrate(bitrate)) + } + + /** + * @return true if the playback speed was changed + */ + fun onSpeedSelected(speed: Float): Boolean { + return viewModel.setPlaybackSpeed(speed) + } + + fun onDecoderSelected(type: DecoderType) { + viewModel.updateDecoderType(type) + } + + fun onSkipToPrevious() { + viewModel.skipToPrevious() + } + + fun onSkipToNext() { + viewModel.skipToNext() + } + + fun onPopupDismissed() { + if (!AndroidVersion.isAtLeastR) { + updateFullscreenState(resources.configuration) + } + } + + fun onUserLeaveHint() { + if (AndroidVersion.isAtLeastN && viewModel.playerOrNull?.isPlaying == true) { + requireActivity().enterPictureInPicture() + } + } + + @Suppress("NestedBlockDepth") + @RequiresApi(Build.VERSION_CODES.N) + private fun Activity.enterPictureInPicture() { + if (AndroidVersion.isAtLeastO) { + val params = PictureInPictureParams.Builder().apply { + val aspectRational = currentVideoStream?.aspectRational?.let { aspectRational -> + when { + aspectRational < PIP_MIN_RATIONAL -> PIP_MIN_RATIONAL + aspectRational > PIP_MAX_RATIONAL -> PIP_MAX_RATIONAL + else -> aspectRational + } + } + setAspectRatio(aspectRational) + val contentFrame: View = playerView.findViewById(ExoplayerR.id.exo_content_frame) + val contentRect = with(contentFrame) { + val (x, y) = intArrayOf(0, 0).also(::getLocationInWindow) + Rect(x, y, x + width, y + height) + } + setSourceRectHint(contentRect) + }.build() + enterPictureInPictureMode(params) + } else { + @Suppress("DEPRECATION") + enterPictureInPictureMode() + } + } + + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { + playerView.useController = !isInPictureInPictureMode + if (isInPictureInPictureMode) { + playerMenus?.dismissPlaybackInfo() + playerLockScreenHelper.hideUnlockButton() + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + Handler(Looper.getMainLooper()).post { + updateFullscreenState(newConfig) + playerGestureHelper.handleConfiguration(newConfig) + } + } + + override fun onStop() { + super.onStop() + orientationListener.disable() + } + + override fun onDestroyView() { + super.onDestroyView() + // Detach player from PlayerView + playerView.player = null + + // Set binding references to null + _playerBinding = null + _playerControlsBinding = null + playerMenus = null + } + + override fun onDestroy() { + super.onDestroy() + with(requireActivity()) { + // Reset screen orientation + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + playerFullscreenHelper.disableFullscreen() + // Reset screen brightness + window.brightness = BRIGHTNESS_OVERRIDE_NONE + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFullscreenHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFullscreenHelper.kt new file mode 100644 index 0000000..afd7dc4 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFullscreenHelper.kt @@ -0,0 +1,42 @@ +package org.jellyfin.mobile.player.ui + +import android.view.View +import android.view.Window +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import org.jellyfin.mobile.utils.AndroidVersion +import org.jellyfin.mobile.utils.extensions.hasFlag + +class PlayerFullscreenHelper(private val window: Window) { + private val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + var isFullscreen: Boolean = false + private set + + fun onWindowInsetsChanged(insets: WindowInsetsCompat) { + isFullscreen = when { + AndroidVersion.isAtLeastR -> { + // Type.systemBars() doesn't work here because this would also check for the navigation bar + // which doesn't exist on all devices + !insets.isVisible(WindowInsetsCompat.Type.statusBars()) + } + else -> { + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility.hasFlag(View.SYSTEM_UI_FLAG_FULLSCREEN) + } + } + } + + fun enableFullscreen() { + windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) + windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + + fun disableFullscreen() { + windowInsetsController.show(WindowInsetsCompat.Type.systemBars()) + } + + fun toggleFullscreen() { + if (isFullscreen) disableFullscreen() else enableFullscreen() + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerGestureHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerGestureHelper.kt new file mode 100644 index 0000000..e943dba --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerGestureHelper.kt @@ -0,0 +1,270 @@ +package org.jellyfin.mobile.player.ui + +import android.content.res.Configuration +import android.media.AudioManager +import android.provider.Settings +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL +import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_OFF +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ProgressBar +import androidx.core.content.getSystemService +import androidx.core.view.isVisible +import androidx.core.view.postDelayed +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout +import com.google.android.exoplayer2.ui.PlayerView +import org.jellyfin.mobile.R +import org.jellyfin.mobile.app.AppPreferences +import org.jellyfin.mobile.databinding.FragmentPlayerBinding +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.utils.brightness +import org.jellyfin.mobile.utils.dip +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import kotlin.math.abs + +class PlayerGestureHelper( + private val fragment: PlayerFragment, + private val playerBinding: FragmentPlayerBinding, + private val playerLockScreenHelper: PlayerLockScreenHelper, +) : KoinComponent { + private val appPreferences: AppPreferences by inject() + private val audioManager: AudioManager by lazy { fragment.requireActivity().getSystemService()!! } + private val playerView: PlayerView by playerBinding::playerView + private val gestureIndicatorOverlayLayout: LinearLayout by playerBinding::gestureOverlayLayout + private val gestureIndicatorOverlayImage: ImageView by playerBinding::gestureOverlayImage + private val gestureIndicatorOverlayProgress: ProgressBar by playerBinding::gestureOverlayProgress + + init { + if (appPreferences.exoPlayerRememberBrightness) { + fragment.requireActivity().window.brightness = appPreferences.exoPlayerBrightness + } + } + + /** + * Tracks whether video content should fill the screen, cutting off unwanted content on the sides. + * Useful on wide-screen phones to remove black bars from some movies. + */ + private var isZoomEnabled = false + + /** + * Tracks a value during a swipe gesture (between multiple onScroll calls). + * When the gesture starts it's reset to an initial value and gets increased or decreased + * (depending on the direction) as the gesture progresses. + */ + private var swipeGestureValueTracker = -1f + + /** + * Runnable that hides [playerView] controller + */ + private val hidePlayerViewControllerAction = Runnable { + playerView.hideController() + } + + /** + * Runnable that hides [gestureIndicatorOverlayLayout] + */ + private val hideGestureIndicatorOverlayAction = Runnable { + gestureIndicatorOverlayLayout.isVisible = false + } + + /** + * Handles taps when controls are locked + */ + private val unlockDetector = GestureDetector( + playerView.context, + object : GestureDetector.SimpleOnGestureListener() { + override fun onSingleTapConfirmed(e: MotionEvent): Boolean { + playerLockScreenHelper.peekUnlockButton() + return true + } + }, + ) + + /** + * Handles double tap to seek and brightness/volume gestures + */ + private val gestureDetector = GestureDetector( + playerView.context, + object : GestureDetector.SimpleOnGestureListener() { + override fun onDoubleTap(e: MotionEvent): Boolean { + val viewWidth = playerView.measuredWidth + val viewHeight = playerView.measuredHeight + val viewCenterX = viewWidth / 2 + val viewCenterY = viewHeight / 2 + val isFastForward = e.x.toInt() > viewCenterX + + // Show ripple effect + playerView.foreground?.apply { + val left = if (isFastForward) viewCenterX else 0 + val right = if (isFastForward) viewWidth else viewCenterX + setBounds(left, viewCenterY - viewCenterX / 2, right, viewCenterY + viewCenterX / 2) + setHotspot(e.x, e.y) + state = intArrayOf(android.R.attr.state_enabled, android.R.attr.state_pressed) + playerView.postDelayed(Constants.DOUBLE_TAP_RIPPLE_DURATION_MS) { + state = IntArray(0) + } + } + + // Fast-forward/rewind + with(fragment) { if (isFastForward) onFastForward() else onRewind() } + + // Cancel previous runnable to not hide controller while seeking + playerView.removeCallbacks(hidePlayerViewControllerAction) + + // Ensure controller gets hidden after seeking + playerView.postDelayed(hidePlayerViewControllerAction, Constants.DEFAULT_CONTROLS_TIMEOUT_MS.toLong()) + return true + } + + override fun onSingleTapConfirmed(e: MotionEvent): Boolean { + playerView.apply { + if (!isControllerVisible) showController() else hideController() + } + return true + } + + override fun onScroll( + firstEvent: MotionEvent?, + currentEvent: MotionEvent, + distanceX: Float, + distanceY: Float, + ): Boolean { + if (!appPreferences.exoPlayerAllowSwipeGestures) { + return false + } + + // Check whether swipe was started in excluded region + val exclusionSize = playerView.resources.dip(Constants.SWIPE_GESTURE_EXCLUSION_SIZE_VERTICAL) + if ( + firstEvent == null || + firstEvent.y < exclusionSize || + firstEvent.y > playerView.height - exclusionSize + ) { + return false + } + + // Check whether swipe was oriented vertically + if (abs(distanceY / distanceX) < 2) { + return false + } + + val viewCenterX = playerView.measuredWidth / 2 + + // Distance to swipe to go from min to max + val distanceFull = playerView.measuredHeight * Constants.FULL_SWIPE_RANGE_SCREEN_RATIO + val ratioChange = distanceY / distanceFull + + if (firstEvent.x.toInt() > viewCenterX) { + // Swiping on the right, change volume + + val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + if (swipeGestureValueTracker == -1f) swipeGestureValueTracker = currentVolume.toFloat() + + val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + val change = ratioChange * maxVolume + swipeGestureValueTracker += change + + val toSet = swipeGestureValueTracker.toInt().coerceIn(0, maxVolume) + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, toSet, 0) + + gestureIndicatorOverlayImage.setImageResource(R.drawable.ic_volume_white_24dp) + gestureIndicatorOverlayProgress.max = maxVolume + gestureIndicatorOverlayProgress.progress = toSet + } else { + // Swiping on the left, change brightness + + val window = fragment.requireActivity().window + val brightnessRange = BRIGHTNESS_OVERRIDE_OFF..BRIGHTNESS_OVERRIDE_FULL + + // Initialize on first swipe + if (swipeGestureValueTracker == -1f) { + val brightness = window.brightness + swipeGestureValueTracker = when (brightness) { + in brightnessRange -> brightness + else -> { + Settings.System.getFloat( + fragment.requireActivity().contentResolver, + Settings.System.SCREEN_BRIGHTNESS, + ) / Constants.SCREEN_BRIGHTNESS_MAX + } + } + } + + swipeGestureValueTracker = (swipeGestureValueTracker + ratioChange).coerceIn(brightnessRange) + window.brightness = swipeGestureValueTracker + if (appPreferences.exoPlayerRememberBrightness) { + appPreferences.exoPlayerBrightness = swipeGestureValueTracker + } + + gestureIndicatorOverlayImage.setImageResource(R.drawable.ic_brightness_white_24dp) + gestureIndicatorOverlayProgress.max = Constants.PERCENT_MAX + gestureIndicatorOverlayProgress.progress = (swipeGestureValueTracker * Constants.PERCENT_MAX).toInt() + } + + gestureIndicatorOverlayLayout.isVisible = true + return true + } + }, + ) + + /** + * Handles scale/zoom gesture + */ + private val zoomGestureDetector = ScaleGestureDetector( + playerView.context, + object : ScaleGestureDetector.OnScaleGestureListener { + override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = fragment.isLandscape() + + override fun onScale(detector: ScaleGestureDetector): Boolean { + val scaleFactor = detector.scaleFactor + if (abs(scaleFactor - Constants.ZOOM_SCALE_BASE) > Constants.ZOOM_SCALE_THRESHOLD) { + isZoomEnabled = scaleFactor > 1 + updateZoomMode(isZoomEnabled) + } + return true + } + + override fun onScaleEnd(detector: ScaleGestureDetector) = Unit + }, + ).apply { isQuickScaleEnabled = false } + + init { + @Suppress("ClickableViewAccessibility") + playerView.setOnTouchListener { _, event -> + if (playerView.useController) { + when (event.pointerCount) { + 1 -> gestureDetector.onTouchEvent(event) + 2 -> zoomGestureDetector.onTouchEvent(event) + } + } else { + unlockDetector.onTouchEvent(event) + } + if (event.action == MotionEvent.ACTION_UP) { + // Hide gesture indicator after timeout, if shown + gestureIndicatorOverlayLayout.apply { + if (isVisible) { + removeCallbacks(hideGestureIndicatorOverlayAction) + postDelayed( + hideGestureIndicatorOverlayAction, + Constants.DEFAULT_CENTER_OVERLAY_TIMEOUT_MS.toLong(), + ) + } + } + swipeGestureValueTracker = -1f + } + true + } + } + + fun handleConfiguration(newConfig: Configuration) { + updateZoomMode(fragment.isLandscape(newConfig) && isZoomEnabled) + } + + private fun updateZoomMode(enabled: Boolean) { + playerView.resizeMode = if (enabled) AspectRatioFrameLayout.RESIZE_MODE_ZOOM else AspectRatioFrameLayout.RESIZE_MODE_FIT + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerLockScreenHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerLockScreenHelper.kt new file mode 100644 index 0000000..71dbf14 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerLockScreenHelper.kt @@ -0,0 +1,67 @@ +package org.jellyfin.mobile.player.ui + +import android.content.pm.ActivityInfo +import android.view.OrientationEventListener +import android.widget.ImageButton +import androidx.core.view.isVisible +import com.google.android.exoplayer2.ui.PlayerView +import org.jellyfin.mobile.databinding.FragmentPlayerBinding +import org.jellyfin.mobile.utils.AndroidVersion +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.utils.extensions.lockOrientation +import org.jellyfin.mobile.utils.isAutoRotateOn + +class PlayerLockScreenHelper( + private val playerFragment: PlayerFragment, + private val playerBinding: FragmentPlayerBinding, + private val orientationListener: OrientationEventListener, +) { + private val playerView: PlayerView by playerBinding::playerView + private val unlockScreenButton: ImageButton by playerBinding::unlockScreenButton + + /** + * Runnable that hides the unlock screen button, used by [peekUnlockButton] + */ + private val hideUnlockButtonAction = Runnable { + hideUnlockButton() + } + + init { + // Handle unlock action + unlockScreenButton.setOnClickListener { + unlockScreen() + } + } + + fun lockScreen() { + playerView.useController = false + orientationListener.disable() + playerFragment.requireActivity().lockOrientation() + peekUnlockButton() + } + + private fun unlockScreen() { + hideUnlockButton() + val activity = playerFragment.requireActivity() + if (activity.isAutoRotateOn()) { + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + orientationListener.enable() + if (!AndroidVersion.isAtLeastN || !activity.isInPictureInPictureMode) { + playerView.useController = true + playerView.apply { + if (!isControllerVisible) showController() + } + } + } + + fun peekUnlockButton() { + playerView.removeCallbacks(hideUnlockButtonAction) + unlockScreenButton.isVisible = true + playerView.postDelayed(hideUnlockButtonAction, Constants.DEFAULT_CONTROLS_TIMEOUT_MS.toLong()) + } + + fun hideUnlockButton() { + unlockScreenButton.isVisible = false + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt new file mode 100644 index 0000000..0768a37 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt @@ -0,0 +1,350 @@ +package org.jellyfin.mobile.player.ui + +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.ImageButton +import android.widget.PopupMenu +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.core.view.get +import androidx.core.view.isVisible +import androidx.core.view.size +import org.jellyfin.mobile.R +import org.jellyfin.mobile.databinding.ExoPlayerControlViewBinding +import org.jellyfin.mobile.databinding.FragmentPlayerBinding +import org.jellyfin.mobile.player.qualityoptions.QualityOptionsProvider +import org.jellyfin.mobile.player.source.JellyfinMediaSource +import org.jellyfin.sdk.model.api.MediaStream +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.util.Locale + +/** + * Provides a menu UI for audio, subtitle and video stream selection + */ +class PlayerMenus( + private val fragment: PlayerFragment, + private val playerBinding: FragmentPlayerBinding, + private val playerControlsBinding: ExoPlayerControlViewBinding, +) : PopupMenu.OnDismissListener, + KoinComponent { + + private val context = playerBinding.root.context + private val qualityOptionsProvider: QualityOptionsProvider by inject() + private val previousButton: View by playerControlsBinding::previousButton + private val nextButton: View by playerControlsBinding::nextButton + private val lockScreenButton: View by playerControlsBinding::lockScreenButton + private val audioStreamsButton: View by playerControlsBinding::audioStreamsButton + private val subtitlesButton: ImageButton by playerControlsBinding::subtitlesButton + private val speedButton: View by playerControlsBinding::speedButton + private val qualityButton: View by playerControlsBinding::qualityButton + private val decoderButton: View by playerControlsBinding::decoderButton + private val infoButton: View by playerControlsBinding::infoButton + private val playbackInfo: TextView by playerBinding::playbackInfo + private val audioStreamsMenu: PopupMenu = createAudioStreamsMenu() + private val subtitlesMenu: PopupMenu = createSubtitlesMenu() + private val speedMenu: PopupMenu = createSpeedMenu() + private val qualityMenu: PopupMenu = createQualityMenu() + private val decoderMenu: PopupMenu = createDecoderMenu() + + private var subtitleCount = 0 + private var subtitlesEnabled = false + + init { + previousButton.setOnClickListener { + fragment.onSkipToPrevious() + } + nextButton.setOnClickListener { + fragment.onSkipToNext() + } + lockScreenButton.setOnClickListener { + fragment.playerLockScreenHelper.lockScreen() + } + audioStreamsButton.setOnClickListener { + fragment.suppressControllerAutoHide(true) + audioStreamsMenu.show() + } + subtitlesButton.setOnClickListener { + when (subtitleCount) { + 0 -> return@setOnClickListener + 1 -> { + fragment.toggleSubtitles { enabled -> + subtitlesEnabled = enabled + updateSubtitlesButton() + } + } + else -> { + fragment.suppressControllerAutoHide(true) + subtitlesMenu.show() + } + } + } + speedButton.setOnClickListener { + fragment.suppressControllerAutoHide(true) + speedMenu.show() + } + qualityButton.setOnClickListener { + fragment.suppressControllerAutoHide(true) + qualityMenu.show() + } + decoderButton.setOnClickListener { + fragment.suppressControllerAutoHide(true) + decoderMenu.show() + } + infoButton.setOnClickListener { + playbackInfo.isVisible = !playbackInfo.isVisible + } + playbackInfo.setOnClickListener { + dismissPlaybackInfo() + } + } + + fun onQueueItemChanged(mediaSource: JellyfinMediaSource, hasNext: Boolean) { + // previousButton is always enabled and will rewind if at the start of the queue + nextButton.isEnabled = hasNext + + val videoStream = mediaSource.selectedVideoStream + + val audioStreams = mediaSource.audioStreams + buildMenuItems( + audioStreamsMenu.menu, + AUDIO_MENU_GROUP, + audioStreams, + mediaSource.selectedAudioStream, + ) + + val subtitleStreams = mediaSource.subtitleStreams + val selectedSubtitleStream = mediaSource.selectedSubtitleStream + buildMenuItems( + subtitlesMenu.menu, + SUBTITLES_MENU_GROUP, + subtitleStreams, + selectedSubtitleStream, + true, + ) + subtitleCount = subtitleStreams.size + subtitlesEnabled = selectedSubtitleStream != null + + updateSubtitlesButton() + + val height = videoStream?.height + val width = videoStream?.width + if (height != null && width != null) { + buildQualityMenu(qualityMenu.menu, mediaSource.maxStreamingBitrate, width, height) + } else { + qualityButton.isVisible = false + } + + val playMethod = context.getString(R.string.playback_info_play_method, mediaSource.playMethod) + val videoTracksInfo = buildMediaStreamsInfo( + mediaStreams = listOfNotNull(videoStream), + prefix = R.string.playback_info_video_streams, + maxStreams = MAX_VIDEO_STREAMS_DISPLAY, + streamSuffix = { stream -> + stream.bitRate?.let { bitrate -> " (${formatBitrate(bitrate.toDouble())})" }.orEmpty() + }, + ) + val audioTracksInfo = buildMediaStreamsInfo( + mediaStreams = audioStreams, + prefix = R.string.playback_info_audio_streams, + maxStreams = MAX_AUDIO_STREAMS_DISPLAY, + streamSuffix = { stream -> + stream.language?.let { lang -> " ($lang)" }.orEmpty() + }, + ) + + playbackInfo.text = listOf( + playMethod, + videoTracksInfo, + audioTracksInfo, + ).joinToString("\n\n") + } + + private fun buildMediaStreamsInfo( + mediaStreams: List, + @StringRes prefix: Int, + maxStreams: Int, + streamSuffix: (MediaStream) -> String, + ): String = mediaStreams.joinToString( + "\n", + "${fragment.getString(prefix)}:\n", + limit = maxStreams, + truncated = fragment.getString(R.string.playback_info_and_x_more, mediaStreams.size - maxStreams), + ) { stream -> + val title = stream.displayTitle?.takeUnless(String::isEmpty) + ?: fragment.getString(R.string.playback_info_stream_unknown_title) + val suffix = streamSuffix(stream) + "- $title$suffix" + } + + private fun createSubtitlesMenu() = PopupMenu(context, subtitlesButton).apply { + setOnMenuItemClickListener { clickedItem -> + // Immediately apply changes to the menu, necessary when direct playing + // When transcoding, the updated media source will cause the menu to be rebuilt + clickedItem.isChecked = true + + // The itemId is the MediaStream.index of the track + val selectedSubtitleStreamIndex = clickedItem.itemId + fragment.onSubtitleSelected(selectedSubtitleStreamIndex) { + subtitlesEnabled = selectedSubtitleStreamIndex >= 0 + updateSubtitlesButton() + } + true + } + setOnDismissListener(this@PlayerMenus) + } + + private fun createAudioStreamsMenu() = PopupMenu(context, audioStreamsButton).apply { + setOnMenuItemClickListener { clickedItem: MenuItem -> + // Immediately apply changes to the menu, necessary when direct playing + // When transcoding, the updated media source will cause the menu to be rebuilt + clickedItem.isChecked = true + + // The itemId is the MediaStream.index of the track + fragment.onAudioTrackSelected(clickedItem.itemId) {} + true + } + setOnDismissListener(this@PlayerMenus) + } + + private fun createSpeedMenu() = PopupMenu(context, speedButton).apply { + for (step in SPEED_MENU_STEP_MIN..SPEED_MENU_STEP_MAX) { + val newSpeed = step * SPEED_MENU_STEP_SIZE + menu.add(SPEED_MENU_GROUP, step, Menu.NONE, "${newSpeed}x").isChecked = newSpeed == 1f + } + menu.setGroupCheckable(SPEED_MENU_GROUP, true, true) + setOnMenuItemClickListener { clickedItem: MenuItem -> + fragment.onSpeedSelected(clickedItem.itemId * SPEED_MENU_STEP_SIZE).also { success -> + if (success) clickedItem.isChecked = true + } + } + setOnDismissListener(this@PlayerMenus) + } + + private fun createQualityMenu() = PopupMenu(context, qualityButton).apply { + setOnMenuItemClickListener { item: MenuItem -> + val newBitrate = item.itemId.takeUnless { bitrate -> bitrate == 0 } + fragment.onBitrateChanged(newBitrate) { + // Ignore callback - menu will be recreated if bitrate changes + } + true + } + setOnDismissListener(this@PlayerMenus) + } + + private fun createDecoderMenu() = PopupMenu(context, qualityButton).apply { + menu.add( + DECODER_MENU_GROUP, + DecoderType.HARDWARE.ordinal, + Menu.NONE, + context.getString(R.string.menu_item_hardware_decoding), + ) + menu.add( + DECODER_MENU_GROUP, + DecoderType.SOFTWARE.ordinal, + Menu.NONE, + context.getString(R.string.menu_item_software_decoding), + ) + menu.setGroupCheckable(DECODER_MENU_GROUP, true, true) + + setOnMenuItemClickListener { clickedItem: MenuItem -> + val type = DecoderType.values()[clickedItem.itemId] + fragment.onDecoderSelected(type) + clickedItem.isChecked = true + true + } + setOnDismissListener(this@PlayerMenus) + } + + fun updatedSelectedDecoder(type: DecoderType) { + decoderMenu.menu.findItem(type.ordinal).isChecked = true + } + + private fun buildMenuItems( + menu: Menu, + groupId: Int, + mediaStreams: List, + selectedStream: MediaStream?, + showNone: Boolean = false, + ) { + menu.clear() + val itemNone = when { + showNone -> menu.add(groupId, -1, Menu.NONE, fragment.getString(R.string.menu_item_none)) + else -> null + } + var selectedItem: MenuItem? = itemNone + val menuItems = mediaStreams.map { mediaStream -> + val title = mediaStream.displayTitle ?: "${mediaStream.language} (${mediaStream.codec})" + menu.add(groupId, mediaStream.index, Menu.NONE, title).also { item -> + if (mediaStream === selectedStream) { + selectedItem = item + } + } + } + menu.setGroupCheckable(groupId, true, true) + // Check selected item or first item if possible + (selectedItem ?: menuItems.firstOrNull())?.isChecked = true + } + + private fun updateSubtitlesButton() { + subtitlesButton.isVisible = subtitleCount > 0 + val stateSet = intArrayOf(android.R.attr.state_checked * if (subtitlesEnabled) 1 else -1) + subtitlesButton.setImageState(stateSet, true) + } + + private fun buildQualityMenu(menu: Menu, maxStreamingBitrate: Int?, videoWidth: Int, videoHeight: Int) { + menu.clear() + val options = qualityOptionsProvider.getApplicableQualityOptions(videoWidth, videoHeight) + options.forEach { option -> + val title = when (val bitrate = option.bitrate) { + 0 -> context.getString(R.string.menu_item_auto) + else -> "${option.maxHeight}p - ${formatBitrate(bitrate.toDouble())}" + } + menu.add(QUALITY_MENU_GROUP, option.bitrate, Menu.NONE, title) + } + menu.setGroupCheckable(QUALITY_MENU_GROUP, true, true) + + val selection = maxStreamingBitrate?.let(menu::findItem) ?: menu[menu.size - 1] // Last element is "auto" + selection.isChecked = true + } + + fun dismissPlaybackInfo() { + playbackInfo.isVisible = false + } + + override fun onDismiss(menu: PopupMenu) { + fragment.suppressControllerAutoHide(false) + fragment.onPopupDismissed() + } + + private fun formatBitrate(bitrate: Double): String { + val (value, unit) = when { + bitrate > BITRATE_MEGA_BIT -> bitrate / BITRATE_MEGA_BIT to " Mbps" + bitrate > BITRATE_KILO_BIT -> bitrate / BITRATE_KILO_BIT to " kbps" + else -> bitrate to " bps" + } + + // Remove unnecessary trailing zeros + val formatted = "%.2f".format(Locale.getDefault(), value).removeSuffix(".00") + return formatted + unit + } + + companion object { + private const val SUBTITLES_MENU_GROUP = 0 + private const val AUDIO_MENU_GROUP = 1 + private const val SPEED_MENU_GROUP = 2 + private const val QUALITY_MENU_GROUP = 3 + private const val DECODER_MENU_GROUP = 4 + + private const val MAX_VIDEO_STREAMS_DISPLAY = 3 + private const val MAX_AUDIO_STREAMS_DISPLAY = 5 + + private const val BITRATE_MEGA_BIT = 1_000_000 + private const val BITRATE_KILO_BIT = 1_000 + + private const val SPEED_MENU_STEP_SIZE = 0.25f + private const val SPEED_MENU_STEP_MIN = 2 // → 0.5x + private const val SPEED_MENU_STEP_MAX = 8 // → 2x + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/TrackSelectionCallback.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/TrackSelectionCallback.kt new file mode 100644 index 0000000..2839d92 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/TrackSelectionCallback.kt @@ -0,0 +1,5 @@ +package org.jellyfin.mobile.player.ui + +fun interface TrackSelectionCallback { + fun onTrackSelected(success: Boolean) +} diff --git a/app/src/main/java/org/jellyfin/mobile/settings/ExternalPlayerPackage.java b/app/src/main/java/org/jellyfin/mobile/settings/ExternalPlayerPackage.java new file mode 100644 index 0000000..a1c4758 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/settings/ExternalPlayerPackage.java @@ -0,0 +1,19 @@ +package org.jellyfin.mobile.settings; + + +import androidx.annotation.StringDef; + +@StringDef({ + ExternalPlayerPackage.MPV_PLAYER, + ExternalPlayerPackage.MX_PLAYER_FREE, + ExternalPlayerPackage.MX_PLAYER_PRO, + ExternalPlayerPackage.VLC_PLAYER, + ExternalPlayerPackage.SYSTEM_DEFAULT +}) +public @interface ExternalPlayerPackage { + String MPV_PLAYER = "is.xyz.mpv"; + String MX_PLAYER_FREE = "com.mxtech.videoplayer.ad"; + String MX_PLAYER_PRO = "com.mxtech.videoplayer.pro"; + String VLC_PLAYER = "org.videolan.vlc"; + String SYSTEM_DEFAULT = "~system~"; +} diff --git a/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt b/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt new file mode 100644 index 0000000..3df1488 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt @@ -0,0 +1,235 @@ +package org.jellyfin.mobile.settings + +import android.content.Intent +import android.os.Bundle +import android.os.Environment +import android.provider.Settings +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE +import androidx.fragment.app.Fragment +import de.Maxr1998.modernpreferences.Preference +import de.Maxr1998.modernpreferences.PreferencesAdapter +import de.Maxr1998.modernpreferences.helpers.categoryHeader +import de.Maxr1998.modernpreferences.helpers.checkBox +import de.Maxr1998.modernpreferences.helpers.defaultOnCheckedChange +import de.Maxr1998.modernpreferences.helpers.defaultOnClick +import de.Maxr1998.modernpreferences.helpers.defaultOnSelectionChange +import de.Maxr1998.modernpreferences.helpers.pref +import de.Maxr1998.modernpreferences.helpers.screen +import de.Maxr1998.modernpreferences.helpers.singleChoice +import de.Maxr1998.modernpreferences.preferences.CheckBoxPreference +import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem +import org.jellyfin.mobile.R +import org.jellyfin.mobile.app.AppPreferences +import org.jellyfin.mobile.databinding.FragmentSettingsBinding +import org.jellyfin.mobile.utils.BackPressInterceptor +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.utils.DownloadMethod +import org.jellyfin.mobile.utils.applyWindowInsetsAsMargins +import org.jellyfin.mobile.utils.extensions.requireMainActivity +import org.jellyfin.mobile.utils.getDownloadsPaths +import org.jellyfin.mobile.utils.isPackageInstalled +import org.jellyfin.mobile.utils.withThemedContext +import org.koin.android.ext.android.inject + +class SettingsFragment : Fragment(), BackPressInterceptor { + + private val appPreferences: AppPreferences by inject() + private val settingsAdapter: PreferencesAdapter by lazy { PreferencesAdapter(buildSettingsScreen()) } + private lateinit var startLandscapeVideoInLandscapePreference: CheckBoxPreference + private lateinit var swipeGesturesPreference: CheckBoxPreference + private lateinit var rememberBrightnessPreference: Preference + private lateinit var backgroundAudioPreference: Preference + private lateinit var directPlayAssPreference: Preference + private lateinit var externalPlayerChoicePreference: Preference + + init { + Preference.Config.titleMaxLines = 2 + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val localInflater = inflater.withThemedContext(requireContext(), R.style.AppTheme_Settings) + val binding = FragmentSettingsBinding.inflate(localInflater, container, false) + binding.root.applyWindowInsetsAsMargins() + binding.toolbar.setTitle(R.string.activity_name_settings) + requireMainActivity().apply { + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + binding.recyclerView.adapter = settingsAdapter + return binding.root + } + + override fun onInterceptBackPressed(): Boolean { + return settingsAdapter.goBack() + } + + override fun onDestroyView() { + super.onDestroyView() + requireMainActivity().setSupportActionBar(null) + } + + @Suppress("LongMethod") + private fun buildSettingsScreen() = screen(requireContext()) { + collapseIcon = true + categoryHeader(PREF_CATEGORY_MUSIC_PLAYER) { + titleRes = R.string.pref_category_music_player + } + checkBox(Constants.PREF_MUSIC_NOTIFICATION_ALWAYS_DISMISSIBLE) { + titleRes = R.string.pref_music_notification_always_dismissible_title + summaryRes = R.string.pref_music_notification_always_dismissible_summary_off + summaryOnRes = R.string.pref_music_notification_always_dismissible_summary_on + } + categoryHeader(PREF_CATEGORY_VIDEO_PLAYER) { + titleRes = R.string.pref_category_video_player + } + val videoPlayerOptions = listOf( + SelectionItem(VideoPlayerType.WEB_PLAYER, R.string.video_player_web, R.string.video_player_web_description), + SelectionItem( + VideoPlayerType.EXO_PLAYER, + R.string.video_player_integrated, + R.string.video_player_native_description, + ), + SelectionItem( + VideoPlayerType.EXTERNAL_PLAYER, + R.string.video_player_external, + R.string.video_player_external_description, + ), + ) + singleChoice(Constants.PREF_VIDEO_PLAYER_TYPE, videoPlayerOptions) { + titleRes = R.string.pref_video_player_type_title + initialSelection = VideoPlayerType.WEB_PLAYER + defaultOnSelectionChange { selection -> + startLandscapeVideoInLandscapePreference.enabled = selection == VideoPlayerType.EXO_PLAYER + swipeGesturesPreference.enabled = selection == VideoPlayerType.EXO_PLAYER + rememberBrightnessPreference.enabled = selection == VideoPlayerType.EXO_PLAYER && swipeGesturesPreference.checked + backgroundAudioPreference.enabled = selection == VideoPlayerType.EXO_PLAYER + directPlayAssPreference.enabled = selection == VideoPlayerType.EXO_PLAYER + externalPlayerChoicePreference.enabled = selection == VideoPlayerType.EXTERNAL_PLAYER + } + } + startLandscapeVideoInLandscapePreference = checkBox(Constants.PREF_EXOPLAYER_START_LANDSCAPE_VIDEO_IN_LANDSCAPE) { + titleRes = R.string.pref_exoplayer_start_landscape_video_in_landscape + enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER + } + swipeGesturesPreference = checkBox(Constants.PREF_EXOPLAYER_ALLOW_SWIPE_GESTURES) { + titleRes = R.string.pref_exoplayer_allow_brightness_volume_gesture + enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER + defaultValue = true + defaultOnCheckedChange { checked -> + rememberBrightnessPreference.enabled = checked + } + } + rememberBrightnessPreference = checkBox(Constants.PREF_EXOPLAYER_REMEMBER_BRIGHTNESS) { + titleRes = R.string.pref_exoplayer_remember_brightness + enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER && appPreferences.exoPlayerAllowSwipeGestures + defaultOnCheckedChange { checked -> + if (!checked) appPreferences.exoPlayerBrightness = BRIGHTNESS_OVERRIDE_NONE + } + } + backgroundAudioPreference = checkBox(Constants.PREF_EXOPLAYER_ALLOW_BACKGROUND_AUDIO) { + titleRes = R.string.pref_exoplayer_allow_background_audio + summaryRes = R.string.pref_exoplayer_allow_background_audio_summary + enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER + } + directPlayAssPreference = checkBox(Constants.PREF_EXOPLAYER_DIRECT_PLAY_ASS) { + titleRes = R.string.pref_exoplayer_direct_play_ass + summaryRes = R.string.pref_exoplayer_direct_play_ass_summary + enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER + } + + // Generate available external player options + val packageManager = requireContext().packageManager + val externalPlayerOptions = listOf( + SelectionItem( + ExternalPlayerPackage.SYSTEM_DEFAULT, + R.string.external_player_system_default, + R.string.external_player_system_default_description, + ), + SelectionItem( + ExternalPlayerPackage.MPV_PLAYER, + R.string.external_player_mpv, + R.string.external_player_mpv_description, + ), + SelectionItem( + ExternalPlayerPackage.MX_PLAYER_FREE, + R.string.external_player_mx_player_free, + R.string.external_player_mx_player_free_description, + ), + SelectionItem( + ExternalPlayerPackage.MX_PLAYER_PRO, + R.string.external_player_mx_player_pro, + R.string.external_player_mx_player_pro_description, + ), + SelectionItem( + ExternalPlayerPackage.VLC_PLAYER, + R.string.external_player_vlc_player, + R.string.external_player_vlc_player_description, + ), + ).filter { item -> + item.key == ExternalPlayerPackage.SYSTEM_DEFAULT || packageManager.isPackageInstalled(item.key) + } + + // Revert if current selection isn't available + if (!packageManager.isPackageInstalled(appPreferences.externalPlayerApp)) { + appPreferences.externalPlayerApp = ExternalPlayerPackage.SYSTEM_DEFAULT + } + + externalPlayerChoicePreference = singleChoice(Constants.PREF_EXTERNAL_PLAYER_APP, externalPlayerOptions) { + titleRes = R.string.external_player_app + enabled = appPreferences.videoPlayerType == VideoPlayerType.EXTERNAL_PLAYER + } + val subtitleSettingsIntent = Intent(Settings.ACTION_CAPTIONING_SETTINGS) + if (subtitleSettingsIntent.resolveActivity(requireContext().packageManager) != null) { + pref(Constants.PREF_SUBTITLE_STYLE) { + titleRes = R.string.pref_subtitle_style + summaryRes = R.string.pref_subtitle_style_summary + defaultOnClick { + startActivity(subtitleSettingsIntent) + } + } + } + categoryHeader(PREF_CATEGORY_DOWNLOADS) { + titleRes = R.string.pref_category_downloads + } + + val downloadMethods = listOf( + SelectionItem( + DownloadMethod.WIFI_ONLY, + R.string.wifi_only, + R.string.wifi_only_summary, + ), + SelectionItem( + DownloadMethod.MOBILE_DATA, + R.string.mobile_data, + R.string.mobile_data_summary, + ), + SelectionItem( + DownloadMethod.MOBILE_AND_ROAMING, + R.string.mobile_data_and_roaming, + R.string.mobile_data_and_roaming_summary, + ), + ) + singleChoice(Constants.PREF_DOWNLOAD_METHOD, downloadMethods) { + titleRes = R.string.network_title + } + + val downloadsDirs = requireContext().getDownloadsPaths().map { path -> + SelectionItem(path, path, null) + } + singleChoice(Constants.PREF_DOWNLOAD_LOCATION, downloadsDirs) { + titleRes = R.string.pref_download_location + initialSelection = Environment + .getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + .absolutePath + } + } + + companion object { + const val PREF_CATEGORY_MUSIC_PLAYER = "pref_category_music" + const val PREF_CATEGORY_VIDEO_PLAYER = "pref_category_video" + const val PREF_CATEGORY_DOWNLOADS = "pref_category_downloads" + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/settings/VideoPlayerType.java b/app/src/main/java/org/jellyfin/mobile/settings/VideoPlayerType.java new file mode 100644 index 0000000..3e8f1f0 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/settings/VideoPlayerType.java @@ -0,0 +1,15 @@ +package org.jellyfin.mobile.settings; + + +import androidx.annotation.StringDef; + +@StringDef({ + VideoPlayerType.WEB_PLAYER, + VideoPlayerType.EXO_PLAYER, + VideoPlayerType.EXTERNAL_PLAYER +}) +public @interface VideoPlayerType { + String WEB_PLAYER = "webui"; + String EXO_PLAYER = "exoplayer"; + String EXTERNAL_PLAYER = "external"; +} diff --git a/app/src/main/java/org/jellyfin/mobile/setup/ConnectFragment.kt b/app/src/main/java/org/jellyfin/mobile/setup/ConnectFragment.kt new file mode 100644 index 0000000..cec54e5 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/setup/ConnectFragment.kt @@ -0,0 +1,46 @@ +package org.jellyfin.mobile.setup + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.core.view.ViewCompat +import androidx.fragment.app.Fragment +import org.jellyfin.mobile.MainViewModel +import org.jellyfin.mobile.databinding.FragmentComposeBinding +import org.jellyfin.mobile.ui.screens.connect.ConnectScreen +import org.jellyfin.mobile.ui.utils.AppTheme +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.utils.applyWindowInsetsAsMargins +import org.koin.androidx.viewmodel.ext.android.activityViewModel + +class ConnectFragment : Fragment() { + private val mainViewModel: MainViewModel by activityViewModel() + private var _viewBinding: FragmentComposeBinding? = null + private val viewBinding get() = _viewBinding!! + private val composeView: ComposeView get() = viewBinding.composeView + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _viewBinding = FragmentComposeBinding.inflate(inflater, container, false) + return composeView.apply { applyWindowInsetsAsMargins() } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Apply window insets + ViewCompat.requestApplyInsets(composeView) + + val encounteredConnectionError = arguments?.getBoolean(Constants.FRAGMENT_CONNECT_EXTRA_ERROR) == true + + composeView.setContent { + AppTheme { + ConnectScreen( + mainViewModel = mainViewModel, + showExternalConnectionError = encounteredConnectionError, + ) + } + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/setup/ConnectionHelper.kt b/app/src/main/java/org/jellyfin/mobile/setup/ConnectionHelper.kt new file mode 100644 index 0000000..77a74b0 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/setup/ConnectionHelper.kt @@ -0,0 +1,94 @@ +package org.jellyfin.mobile.setup + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flowOn +import org.jellyfin.mobile.R +import org.jellyfin.mobile.ui.state.CheckUrlState +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.discovery.LocalServerDiscovery +import org.jellyfin.sdk.discovery.RecommendedServerInfo +import org.jellyfin.sdk.discovery.RecommendedServerInfoScore +import org.jellyfin.sdk.model.api.ServerDiscoveryInfo +import timber.log.Timber + +class ConnectionHelper( + private val context: Context, + private val jellyfin: Jellyfin, +) { + suspend fun checkServerUrl(enteredUrl: String): CheckUrlState { + Timber.i("checkServerUrlAndConnection $enteredUrl") + + val candidates = jellyfin.discovery.getAddressCandidates(enteredUrl) + Timber.i("Address candidates are $candidates") + + // Find servers and classify them into groups. + // BAD servers are collected in case we need an error message, + // GOOD are kept if there's no GREAT one. + val badServers = mutableListOf() + val goodServers = mutableListOf() + val greatServer = jellyfin.discovery.getRecommendedServers(candidates).firstOrNull { recommendedServer -> + when (recommendedServer.score) { + RecommendedServerInfoScore.GREAT -> true + RecommendedServerInfoScore.GOOD -> { + goodServers += recommendedServer + false + } + RecommendedServerInfoScore.OK, + RecommendedServerInfoScore.BAD, + -> { + badServers += recommendedServer + false + } + } + } + + val server = greatServer ?: goodServers.firstOrNull() + if (server != null) { + val systemInfo = requireNotNull(server.systemInfo) + val serverVersion = systemInfo.getOrNull()?.version + Timber.i("Found valid server at ${server.address} with rating ${server.score} and version $serverVersion") + return CheckUrlState.Success(server.address) + } + + // No valid server found, log and show error message + val loggedServers = badServers.joinToString { "${it.address}/${it.systemInfo}" } + Timber.i("No valid servers found, invalid candidates were: $loggedServers") + + val error = when { + badServers.isNotEmpty() -> { + val count = badServers.size + val (unreachableServers, incompatibleServers) = badServers.partition { result -> result.systemInfo.getOrNull() == null } + + StringBuilder(context.resources.getQuantityString(R.plurals.connection_error_prefix, count, count)).apply { + if (unreachableServers.isNotEmpty()) { + append("\n\n") + append(context.getString(R.string.connection_error_unable_to_reach_sever)) + append(":\n") + append( + unreachableServers.joinToString(separator = "\n") { result -> "\u00b7 ${result.address}" }, + ) + } + if (incompatibleServers.isNotEmpty()) { + append("\n\n") + append(context.getString(R.string.connection_error_unsupported_version_or_product)) + append(":\n") + append( + incompatibleServers.joinToString(separator = "\n") { result -> "\u00b7 ${result.address}" }, + ) + } + }.toString() + } + else -> null + } + + return CheckUrlState.Error(error) + } + + fun discoverServersAsFlow(): Flow = + jellyfin.discovery + .discoverLocalServers(maxServers = LocalServerDiscovery.DISCOVERY_MAX_SERVERS) + .flowOn(Dispatchers.IO) +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ConnectScreen.kt b/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ConnectScreen.kt new file mode 100644 index 0000000..8c02bfd --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ConnectScreen.kt @@ -0,0 +1,54 @@ +package org.jellyfin.mobile.ui.screens.connect + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import org.jellyfin.mobile.MainViewModel +import org.jellyfin.mobile.R +import org.jellyfin.mobile.ui.utils.CenterRow + +@Composable +fun ConnectScreen( + mainViewModel: MainViewModel, + showExternalConnectionError: Boolean, +) { + Surface(color = MaterialTheme.colors.background) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + ) { + LogoHeader() + ServerSelection( + showExternalConnectionError = showExternalConnectionError, + onConnected = { hostname -> + mainViewModel.switchServer(hostname) + }, + ) + } + } +} + +@Stable +@Composable +fun LogoHeader() { + CenterRow( + modifier = Modifier.padding(vertical = 25.dp), + ) { + Image( + painter = painterResource(R.drawable.app_logo), + modifier = Modifier + .height(72.dp), + contentDescription = null, + ) + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ServerSelection.kt b/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ServerSelection.kt new file mode 100644 index 0000000..dd32a64 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ServerSelection.kt @@ -0,0 +1,356 @@ +package org.jellyfin.mobile.ui.screens.connect + +import android.view.KeyEvent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.ListItem +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +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 kotlinx.coroutines.launch +import org.jellyfin.mobile.R +import org.jellyfin.mobile.app.ApiClientController +import org.jellyfin.mobile.setup.ConnectionHelper +import org.jellyfin.mobile.ui.state.CheckUrlState +import org.jellyfin.mobile.ui.state.ServerSelectionMode +import org.jellyfin.mobile.ui.utils.CenterRow +import org.koin.compose.koinInject + +@OptIn(ExperimentalComposeUiApi::class) +@Suppress("LongMethod") +@Composable +fun ServerSelection( + showExternalConnectionError: Boolean, + apiClientController: ApiClientController = koinInject(), + connectionHelper: ConnectionHelper = koinInject(), + onConnected: suspend (String) -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + val keyboardController = LocalSoftwareKeyboardController.current + var serverSelectionMode by remember { mutableStateOf(ServerSelectionMode.ADDRESS) } + var hostname by remember { mutableStateOf("") } + val serverSuggestions = remember { mutableStateListOf() } + var checkUrlState by remember> { mutableStateOf(CheckUrlState.Unchecked) } + var externalError by remember { mutableStateOf(showExternalConnectionError) } + + // Prefill currently selected server if available + LaunchedEffect(Unit) { + val server = apiClientController.loadSavedServer() + if (server != null) { + hostname = server.hostname + } + } + + LaunchedEffect(Unit) { + // Suggest saved servers + apiClientController.loadPreviouslyUsedServers().mapTo(serverSuggestions) { server -> + ServerSuggestion( + type = ServerSuggestion.Type.SAVED, + name = server.hostname, + address = server.hostname, + timestamp = server.lastUsedTimestamp, + ) + } + + // Prepend discovered servers to suggestions + connectionHelper.discoverServersAsFlow().collect { serverInfo -> + serverSuggestions.removeIf { existing -> existing.address == serverInfo.address } + serverSuggestions.add( + index = 0, + ServerSuggestion( + type = ServerSuggestion.Type.DISCOVERED, + name = serverInfo.name, + address = serverInfo.address, + timestamp = System.currentTimeMillis(), + ), + ) + } + } + + fun onSubmit() { + externalError = false + checkUrlState = CheckUrlState.Pending + coroutineScope.launch { + val state = connectionHelper.checkServerUrl(hostname) + checkUrlState = state + if (state is CheckUrlState.Success) { + onConnected(state.address) + } + } + } + + Column { + Text( + text = stringResource(R.string.connect_to_server_title), + modifier = Modifier.padding(bottom = 8.dp), + style = MaterialTheme.typography.h5, + ) + Crossfade( + targetState = serverSelectionMode, + label = "Server selection mode", + ) { selectionType -> + when (selectionType) { + ServerSelectionMode.ADDRESS -> AddressSelection( + text = hostname, + errorText = when { + externalError -> stringResource(R.string.connection_error_cannot_connect) + else -> (checkUrlState as? CheckUrlState.Error)?.message + }, + loading = checkUrlState is CheckUrlState.Pending, + onTextChange = { value -> + externalError = false + checkUrlState = CheckUrlState.Unchecked + hostname = value + }, + onDiscoveryClick = { + externalError = false + keyboardController?.hide() + serverSelectionMode = ServerSelectionMode.AUTO_DISCOVERY + }, + onSubmit = { + onSubmit() + }, + ) + ServerSelectionMode.AUTO_DISCOVERY -> ServerDiscoveryList( + serverSuggestions = serverSuggestions, + onGoBack = { + serverSelectionMode = ServerSelectionMode.ADDRESS + }, + onSelectServer = { url -> + hostname = url + serverSelectionMode = ServerSelectionMode.ADDRESS + onSubmit() + }, + ) + } + } + } +} + +@Stable +@Composable +private fun AddressSelection( + text: String, + errorText: String?, + loading: Boolean, + onTextChange: (String) -> Unit, + onDiscoveryClick: () -> Unit, + onSubmit: () -> Unit, +) { + Column { + ServerUrlField( + text = text, + errorText = errorText, + onTextChange = onTextChange, + onSubmit = onSubmit, + ) + AnimatedErrorText(errorText = errorText) + if (!loading) { + Spacer(modifier = Modifier.height(12.dp)) + StyledTextButton( + text = stringResource(R.string.connect_button_text), + enabled = text.isNotBlank(), + onClick = onSubmit, + ) + StyledTextButton( + text = stringResource(R.string.choose_server_button_text), + onClick = onDiscoveryClick, + ) + } else { + CenterRow { + CircularProgressIndicator(modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)) + } + } + } +} + +@Stable +@Composable +private fun ServerUrlField( + text: String, + errorText: String?, + onTextChange: (String) -> Unit, + onSubmit: () -> Unit, +) { + OutlinedTextField( + value = text, + onValueChange = onTextChange, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + .onKeyEvent { keyEvent -> + when (keyEvent.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_ENTER -> { + onSubmit() + true + } + else -> false + } + }, + label = { + Text(text = stringResource(R.string.host_input_hint)) + }, + isError = errorText != null, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + ), + keyboardActions = KeyboardActions( + onGo = { + onSubmit() + }, + ), + singleLine = true, + ) +} + +@Stable +@Composable +private fun AnimatedErrorText( + errorText: String?, +) { + AnimatedVisibility( + visible = errorText != null, + exit = ExitTransition.None, + ) { + Text( + text = errorText.orEmpty(), + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + color = MaterialTheme.colors.error, + style = MaterialTheme.typography.caption, + ) + } +} + +@Stable +@Composable +private fun StyledTextButton( + text: String, + enabled: Boolean = true, + onClick: () -> Unit, +) { + TextButton( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + enabled = enabled, + colors = ButtonDefaults.buttonColors(), + ) { + Text(text = text) + } +} + +@Stable +@Composable +private fun ServerDiscoveryList( + serverSuggestions: SnapshotStateList, + onGoBack: () -> Unit, + onSelectServer: (String) -> Unit, +) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onGoBack) { + Icon(imageVector = Icons.Outlined.ArrowBack, contentDescription = null) + } + Text( + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp), + text = stringResource(R.string.available_servers_title), + ) + CircularProgressIndicator( + modifier = Modifier + .padding(horizontal = 12.dp) + .size(24.dp), + ) + } + Spacer(modifier = Modifier.height(8.dp)) + LazyColumn( + modifier = Modifier + .padding(bottom = 16.dp) + .fillMaxSize() + .background( + color = MaterialTheme.colors.surface, + shape = MaterialTheme.shapes.medium, + ), + ) { + items(serverSuggestions) { server -> + ServerDiscoveryItem( + serverSuggestion = server, + onClickServer = { + onSelectServer(server.address) + }, + ) + } + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Stable +@Composable +private fun ServerDiscoveryItem( + serverSuggestion: ServerSuggestion, + onClickServer: () -> Unit, +) { + ListItem( + modifier = Modifier + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = onClickServer), + text = { + Text(text = serverSuggestion.name) + }, + secondaryText = { + Text(text = serverSuggestion.address) + }, + ) +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ServerSuggestion.kt b/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ServerSuggestion.kt new file mode 100644 index 0000000..85c19b4 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/screens/connect/ServerSuggestion.kt @@ -0,0 +1,18 @@ +package org.jellyfin.mobile.ui.screens.connect + +data class ServerSuggestion( + val type: Type, + val name: String, + val address: String, + /** + * A timestamp for this suggestion, used for sorting. + * For discovered servers, this should be the discovery time, + * for saved servers, this should be the last used time. + */ + val timestamp: Long, +) { + enum class Type { + DISCOVERED, + SAVED, + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/state/CheckUrlState.kt b/app/src/main/java/org/jellyfin/mobile/ui/state/CheckUrlState.kt new file mode 100644 index 0000000..f6a9743 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/state/CheckUrlState.kt @@ -0,0 +1,8 @@ +package org.jellyfin.mobile.ui.state + +sealed class CheckUrlState { + object Unchecked : CheckUrlState() + object Pending : CheckUrlState() + class Success(val address: String) : CheckUrlState() + class Error(val message: String?) : CheckUrlState() +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/state/ServerSelectionMode.kt b/app/src/main/java/org/jellyfin/mobile/ui/state/ServerSelectionMode.kt new file mode 100644 index 0000000..3045207 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/state/ServerSelectionMode.kt @@ -0,0 +1,6 @@ +package org.jellyfin.mobile.ui.state + +enum class ServerSelectionMode { + ADDRESS, + AUTO_DISCOVERY, +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/utils/AppTheme.kt b/app/src/main/java/org/jellyfin/mobile/ui/utils/AppTheme.kt new file mode 100644 index 0000000..05bc9b2 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/utils/AppTheme.kt @@ -0,0 +1,38 @@ +package org.jellyfin.mobile.ui.utils + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Shapes +import androidx.compose.material.darkColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun AppTheme(content: @Composable () -> Unit) { + val colors = remember { + @Suppress("MagicNumber") + darkColors( + primary = Color(0xFF00A4DC), + primaryVariant = Color(0xFF202020), + background = Color(0xFF101010), + surface = Color(0xFF363636), + error = Color(0xFFCF6679), + onPrimary = Color.White, + onSecondary = Color.White, + onBackground = Color.White, + onSurface = Color.White, + onError = Color.White, + ) + } + MaterialTheme( + colors = colors, + shapes = Shapes( + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(8.dp), + large = RoundedCornerShape(0.dp), + ), + content = content, + ) +} diff --git a/app/src/main/java/org/jellyfin/mobile/ui/utils/CenterRow.kt b/app/src/main/java/org/jellyfin/mobile/ui/utils/CenterRow.kt new file mode 100644 index 0000000..db12665 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/ui/utils/CenterRow.kt @@ -0,0 +1,22 @@ +package org.jellyfin.mobile.ui.utils + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +inline fun CenterRow( + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) = Row( + modifier = Modifier + .fillMaxWidth() + .then(modifier), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + content = content, +) diff --git a/app/src/main/java/org/jellyfin/mobile/utils/AndroidVersion.kt b/app/src/main/java/org/jellyfin/mobile/utils/AndroidVersion.kt new file mode 100644 index 0000000..92a58fb --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/AndroidVersion.kt @@ -0,0 +1,92 @@ +package org.jellyfin.mobile.utils + +import android.os.Build + +/** + * Helper class to check the current Android version. + * + * Comparisons will be made against the current device's Android SDK version number in [Build.VERSION.SDK_INT]. + * + * @see Build.VERSION.SDK_INT + */ +object AndroidVersion { + /** + * Checks whether the current Android version is at least Android 6 Marshmallow, API 23. + * + * @see Build.VERSION_CODES.M + */ + inline val isAtLeastM: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + + /** + * Checks whether the current Android version is at least Android 7 Nougat, API 24. + * + * @see Build.VERSION_CODES.N + */ + inline val isAtLeastN: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N + + /** + * Checks whether the current Android version is at least Android 7.1 Nougat, API 25. + * + * @see Build.VERSION_CODES.N_MR1 + */ + inline val isAtLeastNMR1: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 + + /** + * Checks whether the current Android version is at least Android 8 Oreo, API 26. + * + * @see Build.VERSION_CODES.O + */ + inline val isAtLeastO: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + + /** + * Checks whether the current Android version is at least Android 9 Pie, API 28. + * + * @see Build.VERSION_CODES.P + */ + inline val isAtLeastP: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + + /** + * Checks whether the current Android version is at least Android 10 Q, API 29. + * + * @see Build.VERSION_CODES.Q + */ + inline val isAtLeastQ: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + + /** + * Checks whether the current Android version is at least Android 11 R, API 30. + * + * @see Build.VERSION_CODES.R + */ + inline val isAtLeastR: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + + /** + * Checks whether the current Android version is at least Android 12 S, API 31. + * + * @see Build.VERSION_CODES.S + */ + inline val isAtLeastS: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + + /** + * Checks whether the current Android version is at least Android 12 S V2, API 32. + * + * @see Build.VERSION_CODES.S_V2 + */ + inline val isAtLeastSV2: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S_V2 + + /** + * Checks whether the current Android version is at least Android 13 Tiramisu, API 33. + * + * @see Build.VERSION_CODES.TIRAMISU + */ + inline val isAtLeastT: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU +} diff --git a/app/src/main/java/org/jellyfin/mobile/utils/BackPressInterceptor.kt b/app/src/main/java/org/jellyfin/mobile/utils/BackPressInterceptor.kt new file mode 100644 index 0000000..4a610b5 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/BackPressInterceptor.kt @@ -0,0 +1,41 @@ +package org.jellyfin.mobile.utils + +import androidx.activity.OnBackPressedDispatcher +import androidx.fragment.app.Fragment +import org.jellyfin.mobile.MainActivity +import org.jellyfin.mobile.utils.extensions.addFragment + +/** + * Additional hook for handling back presses in [Fragments][Fragment] (see [onInterceptBackPressed]). + * + * This hook is introduced since the AndroidX onBackPressedDispatcher system does not play well with the way we handle fragments: + * The WebViewFragment always needs to be active, since it contains the state of the web interface. + * To achieve this, we only add fragments (see [addFragment]) instead of doing the more common way + * and replacing the current fragment. + * + * This keeps the WebViewFragment alive, but unless the new fragment registers its own onBackPressedCallback, + * this also means that the WebViewFragment's onBackPressedCallbacks would still be the topmost dispatcher and therefore + * would be called (see [OnBackPressedDispatcher.onBackPressed]). + * + * This wouldn't be a problem if there was some way for the WebViewFragment (or any other fragment that's active) to + * know if it is the currently displayed fragment, since then it could deactivate its own onBackPressedCallback + * and the next callback would be called instead. + * The [MainActivity's][MainActivity] callback would then default to popping the backstack. + * + * There might be a way to implement this by using the backstack to determine if the current fragment is the topmost fragment, + * but sadly it seems that this isn't possible in a non-hacky way (as in hardcoding names of backstack entries). + * + * Instead, the MainActivity determines the currently visible fragment, + * and passes the back press event to it via the [onInterceptBackPressed] method. + */ +interface BackPressInterceptor { + /** + * Called when a back press is performed while this fragment is currently visible. + * + * @return `true` if the event was intercepted by the fragment, + * `false` if the back press was not handled by the fragment. + * The latter will result in a default action that closes the fragment + * @see MainActivity.onBackPressedCallback + */ + fun onInterceptBackPressed(): Boolean = false +} diff --git a/app/src/main/java/org/jellyfin/mobile/utils/BluetoothPermissionHelper.kt b/app/src/main/java/org/jellyfin/mobile/utils/BluetoothPermissionHelper.kt new file mode 100644 index 0000000..9532d09 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/BluetoothPermissionHelper.kt @@ -0,0 +1,67 @@ +package org.jellyfin.mobile.utils + +import android.Manifest.permission.BLUETOOTH_CONNECT +import android.app.AlertDialog +import android.content.pm.PackageManager +import android.content.pm.PackageManager.PERMISSION_GRANTED +import kotlinx.coroutines.suspendCancellableCoroutine +import org.jellyfin.mobile.MainActivity +import org.jellyfin.mobile.R +import org.jellyfin.mobile.app.AppPreferences +import kotlin.coroutines.resume + +class BluetoothPermissionHelper( + private val activity: MainActivity, + private val appPreferences: AppPreferences, +) { + /** + * This is used to prevent the dialog from showing multiple times in a single session (activity creation). + * Otherwise, the package manager and permission would need to be queried on every media event. + */ + private var wasDialogShowThisSession = false + + @Suppress("ComplexCondition") + suspend fun requestBluetoothPermissionIfNecessary() { + // Check conditions by increasing complexity + if ( + !AndroidVersion.isAtLeastS || + wasDialogShowThisSession || + activity.checkSelfPermission(BLUETOOTH_CONNECT) == PERMISSION_GRANTED || + appPreferences.ignoreBluetoothPermission || + !activity.packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH) + ) { + return + } + + wasDialogShowThisSession = true + + val shouldRequestPermission = suspendCancellableCoroutine { continuation -> + AlertDialog.Builder(activity) + .setTitle(R.string.bluetooth_permission_title) + .setMessage(R.string.bluetooth_permission_message) + .setNegativeButton(android.R.string.cancel) { dialog, _ -> + dialog.dismiss() + continuation.resume(false) + } + .setPositiveButton(R.string.bluetooth_permission_continue) { dialog, _ -> + dialog.dismiss() + continuation.resume(true) + } + .setOnCancelListener { + continuation.resume(false) + } + .show() + } + + if (!shouldRequestPermission) { + appPreferences.ignoreBluetoothPermission = true + return + } + + activity.requestPermission(BLUETOOTH_CONNECT) { requestPermissionsResult -> + if (requestPermissionsResult[BLUETOOTH_CONNECT] == PERMISSION_GRANTED) { + activity.toast(R.string.bluetooth_permission_granted) + } + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/utils/CombinedIntRange.kt b/app/src/main/java/org/jellyfin/mobile/utils/CombinedIntRange.kt new file mode 100644 index 0000000..db44e7f --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/CombinedIntRange.kt @@ -0,0 +1,5 @@ +package org.jellyfin.mobile.utils + +class CombinedIntRange(private vararg val ranges: IntRange) { + operator fun contains(value: Int) = ranges.any { range -> range.contains(value) } +} diff --git a/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt b/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt new file mode 100644 index 0000000..5715818 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt @@ -0,0 +1,148 @@ +package org.jellyfin.mobile.utils + +import android.app.PendingIntent +import android.media.session.PlaybackState +import android.util.Rational +import org.jellyfin.mobile.BuildConfig + +@Suppress("MagicNumber") +object Constants { + // App Info + const val APP_INFO_NAME = "Jellyfin Android" + const val APP_INFO_VERSION: String = BuildConfig.VERSION_NAME + + // Webapp constants + const val MINIMUM_WEB_VIEW_VERSION = 80 + const val SHOW_PROGRESS_BAR_DELAY = 1000L + const val INITIAL_CONNECTION_TIMEOUT = 10000L // 10 seconds + val MAIN_BUNDLE_PATH_REGEX = Regex(""".*/main\.[^/\s]+\.bundle\.js""") + const val CAST_SDK_PATH = "cast_sender.js" + const val SESSION_CAPABILITIES_PATH = "sessions/capabilities/full" + const val SERVICE_WORKER_PATH = "serviceworker.js" + + const val FRAGMENT_CONNECT_EXTRA_ERROR = "org.jellyfin.mobile.intent.extra.ERROR" + const val FRAGMENT_WEB_VIEW_EXTRA_SERVER = "org.jellyfin.mobile.intent.extra.SERVER" + + // Preference keys + const val PREF_SERVER_ID = "pref_server_id" + const val PREF_USER_ID = "pref_user_id" + const val PREF_INSTANCE_URL = "pref_instance_url" + const val PREF_IGNORE_BATTERY_OPTIMIZATIONS = "pref_ignore_battery_optimizations" + const val PREF_IGNORE_WEBVIEW_CHECKS = "pref_ignore_webview_checks" + const val PREF_IGNORE_BLUETOOTH_PERMISSION = "pref_ignore_bluetooth_permission" + const val PREF_DOWNLOAD_METHOD = "pref_download_method" + const val PREF_MUSIC_NOTIFICATION_ALWAYS_DISMISSIBLE = "pref_music_notification_always_dismissible" + const val PREF_VIDEO_PLAYER_TYPE = "pref_video_player_type" + const val PREF_EXOPLAYER_START_LANDSCAPE_VIDEO_IN_LANDSCAPE = "pref_exoplayer_start_landscape_video_in_landscape" + const val PREF_EXOPLAYER_ALLOW_SWIPE_GESTURES = "pref_exoplayer_allow_swipe_gestures" + const val PREF_EXOPLAYER_REMEMBER_BRIGHTNESS = "pref_exoplayer_remember_brightness" + const val PREF_EXOPLAYER_BRIGHTNESS = "pref_exoplayer_brightness" + const val PREF_EXOPLAYER_ALLOW_BACKGROUND_AUDIO = "pref_exoplayer_allow_background_audio" + const val PREF_EXOPLAYER_DIRECT_PLAY_ASS = "pref_exoplayer_direct_play_ass" + const val PREF_EXTERNAL_PLAYER_APP = "pref_external_player_app" + const val PREF_SUBTITLE_STYLE = "pref_subtitle_style" + const val PREF_DOWNLOAD_LOCATION = "pref_download_location" + + // InputManager commands + const val PLAYBACK_MANAGER_COMMAND_PLAY = "unpause" + const val PLAYBACK_MANAGER_COMMAND_PAUSE = "pause" + const val PLAYBACK_MANAGER_COMMAND_PREVIOUS = "previousTrack" + const val PLAYBACK_MANAGER_COMMAND_NEXT = "nextTrack" + const val PLAYBACK_MANAGER_COMMAND_REWIND = "rewind" + const val PLAYBACK_MANAGER_COMMAND_FAST_FORWARD = "fastForward" + const val PLAYBACK_MANAGER_COMMAND_STOP = "stop" + const val PLAYBACK_MANAGER_COMMAND_VOL_UP = "volumeUp" + const val PLAYBACK_MANAGER_COMMAND_VOL_DOWN = "volumeDown" + + // Notification + val PENDING_INTENT_FLAGS = PendingIntent.FLAG_UPDATE_CURRENT or when { + AndroidVersion.isAtLeastM -> PendingIntent.FLAG_IMMUTABLE + else -> 0 + } + const val MEDIA_NOTIFICATION_CHANNEL_ID = "org.jellyfin.mobile.media.NOW_PLAYING" + + // Music player constants + const val SUPPORTED_MUSIC_PLAYER_PLAYBACK_ACTIONS: Long = PlaybackState.ACTION_PLAY_PAUSE or + PlaybackState.ACTION_PLAY or + PlaybackState.ACTION_PAUSE or + PlaybackState.ACTION_STOP or + PlaybackState.ACTION_SKIP_TO_NEXT or + PlaybackState.ACTION_SKIP_TO_PREVIOUS or + PlaybackState.ACTION_SET_RATING + const val MEDIA_PLAYER_NOTIFICATION_ID = 42 + const val REMOTE_PLAYER_CONTENT_INTENT_REQUEST_CODE = 100 + + // Music player intent actions + const val ACTION_SHOW_PLAYER = "org.jellyfin.mobile.intent.action.SHOW_PLAYER" + const val ACTION_PLAY = "org.jellyfin.mobile.intent.action.PLAY" + const val ACTION_PAUSE = "org.jellyfin.mobile.intent.action.PAUSE" + const val ACTION_REWIND = "org.jellyfin.mobile.intent.action.REWIND" + const val ACTION_FAST_FORWARD = "org.jellyfin.mobile.intent.action.FAST_FORWARD" + const val ACTION_PREVIOUS = "org.jellyfin.mobile.intent.action.PREVIOUS" + const val ACTION_NEXT = "org.jellyfin.mobile.intent.action.NEXT" + const val ACTION_STOP = "org.jellyfin.mobile.intent.action.STOP" + const val ACTION_REPORT = "org.jellyfin.mobile.intent.action.REPORT" + + // Music player intent extras + const val EXTRA_PLAYER_ACTION = "action" + const val EXTRA_ITEM_ID = "itemId" + const val EXTRA_TITLE = "title" + const val EXTRA_ARTIST = "artist" + const val EXTRA_ALBUM = "album" + const val EXTRA_IMAGE_URL = "imageUrl" + const val EXTRA_POSITION = "position" + const val EXTRA_DURATION = "duration" + const val EXTRA_CAN_SEEK = "canSeek" + const val EXTRA_IS_LOCAL_PLAYER = "isLocalPlayer" + const val EXTRA_IS_PAUSED = "isPaused" + + // Video player constants + const val LANGUAGE_UNDEFINED = "und" + const val TICKS_PER_MILLISECOND = 10000 + const val PLAYER_TIME_UPDATE_RATE = 10000L + const val DEFAULT_CONTROLS_TIMEOUT_MS = 2500 + const val SWIPE_GESTURE_EXCLUSION_SIZE_VERTICAL = 64 + const val DEFAULT_CENTER_OVERLAY_TIMEOUT_MS = 250 + const val DISPLAY_PREFERENCES_ID_USER_SETTINGS = "usersettings" + const val DISPLAY_PREFERENCES_CLIENT_EMBY = "emby" + const val DISPLAY_PREFERENCES_SKIP_BACK_LENGTH = "skipBackLength" + const val DISPLAY_PREFERENCES_SKIP_FORWARD_LENGTH = "skipForwardLength" + const val DEFAULT_SEEK_TIME_MS = 10000L + const val MAX_SKIP_TO_PREV_MS = 3000L + const val DOUBLE_TAP_RIPPLE_DURATION_MS = 100L + const val FULL_SWIPE_RANGE_SCREEN_RATIO = 0.66f + const val SCREEN_BRIGHTNESS_MAX = 255 + const val ZOOM_SCALE_BASE = 1f + const val ZOOM_SCALE_THRESHOLD = 0.01f + val ASPECT_RATIO_16_9 = Rational(16, 9) + val PIP_MIN_RATIONAL = Rational(100, 239) + val PIP_MAX_RATIONAL = Rational(239, 100) + const val SUPPORTED_VIDEO_PLAYER_PLAYBACK_ACTIONS: Long = PlaybackState.ACTION_PLAY_PAUSE or + PlaybackState.ACTION_PLAY or + PlaybackState.ACTION_PAUSE or + PlaybackState.ACTION_SEEK_TO or + PlaybackState.ACTION_REWIND or + PlaybackState.ACTION_FAST_FORWARD or + PlaybackState.ACTION_STOP + const val VIDEO_PLAYER_NOTIFICATION_ID = 99 + + // Video player intent extras + const val EXTRA_MEDIA_PLAY_OPTIONS = "org.jellyfin.mobile.MEDIA_PLAY_OPTIONS" + + // External player result actions + const val MPV_PLAYER_RESULT_ACTION = "is.xyz.mpv.MPVActivity.result" + const val MX_PLAYER_RESULT_ACTION = "com.mxtech.intent.result.VIEW" + const val VLC_PLAYER_RESULT_ACTION = "org.videolan.vlc.player.result" + + // External player webapp events + const val EVENT_ENDED = "Ended" + const val EVENT_TIME_UPDATE = "TimeUpdate" + const val EVENT_CANCELED = "Canceled" + + // Orientation constants + val ORIENTATION_PORTRAIT_RANGE = CombinedIntRange(340..360, 0..20) + val ORIENTATION_LANDSCAPE_RANGE = CombinedIntRange(70..110, 250..290) + + // Misc + const val PERCENT_MAX = 100 +} diff --git a/app/src/main/java/org/jellyfin/mobile/utils/DownloadMethod.java b/app/src/main/java/org/jellyfin/mobile/utils/DownloadMethod.java new file mode 100644 index 0000000..f36b1b4 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/DownloadMethod.java @@ -0,0 +1,14 @@ +package org.jellyfin.mobile.utils; + +import static org.jellyfin.mobile.utils.DownloadMethod.MOBILE_AND_ROAMING; +import static org.jellyfin.mobile.utils.DownloadMethod.MOBILE_DATA; +import static org.jellyfin.mobile.utils.DownloadMethod.WIFI_ONLY; + +import androidx.annotation.IntDef; + +@IntDef({WIFI_ONLY, MOBILE_DATA, MOBILE_AND_ROAMING}) +public @interface DownloadMethod { + int WIFI_ONLY = 0; + int MOBILE_DATA = 1; + int MOBILE_AND_ROAMING = 2; +} diff --git a/app/src/main/java/org/jellyfin/mobile/utils/JellyTree.kt b/app/src/main/java/org/jellyfin/mobile/utils/JellyTree.kt new file mode 100644 index 0000000..0e6db17 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/JellyTree.kt @@ -0,0 +1,11 @@ +package org.jellyfin.mobile.utils + +import android.util.Log +import org.jellyfin.mobile.BuildConfig +import timber.log.Timber + +class JellyTree : Timber.DebugTree() { + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + if (BuildConfig.DEBUG || priority >= Log.INFO) super.log(priority, tag, message, t) + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/utils/LocaleUtils.kt b/app/src/main/java/org/jellyfin/mobile/utils/LocaleUtils.kt new file mode 100644 index 0000000..e727838 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/LocaleUtils.kt @@ -0,0 +1,55 @@ +package org.jellyfin.mobile.utils + +import android.content.Context +import android.content.res.Configuration +import android.webkit.WebView +import org.json.JSONException +import org.json.JSONObject +import timber.log.Timber +import java.util.Locale +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +suspend fun WebView.initLocale(userId: String) { + // Try to set locale via user settings + val userSettings = suspendCoroutine { continuation -> + evaluateJavascript("window.localStorage.getItem('$userId-language')") { result -> + try { + continuation.resume(JSONObject("{locale:$result}").getString("locale")) + } catch (e: JSONException) { + continuation.resume(null) + } + } + } + + if (context.setLocale(userSettings)) return + + // Fallback to device locale + Timber.i("Couldn't acquire locale from config, keeping current") +} + +private fun Context.setLocale(localeString: String?): Boolean { + if (localeString.isNullOrEmpty()) return false + + val localeSplit = localeString.split('-') + val locale = when (localeSplit.size) { + 1 -> Locale(localeString, "") + 2 -> Locale(localeSplit[0], localeSplit[1]) + else -> return false + } + + val configuration = resources.configuration + if (locale != configuration.primaryLocale) { + Locale.setDefault(locale) + configuration.setLocale(locale) + @Suppress("DEPRECATION") + resources.updateConfiguration(configuration, resources.displayMetrics) + + Timber.i("Updated locale from web: '$locale'") + } // else: Locale is already applied + return true +} + +@Suppress("DEPRECATION") +private val Configuration.primaryLocale: Locale + get() = if (AndroidVersion.isAtLeastN) locales[0] else locale diff --git a/app/src/main/java/org/jellyfin/mobile/utils/MediaExtensions.kt b/app/src/main/java/org/jellyfin/mobile/utils/MediaExtensions.kt new file mode 100644 index 0000000..66b695d --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/MediaExtensions.kt @@ -0,0 +1,97 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package org.jellyfin.mobile.utils + +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.MediaMetadata +import android.media.session.MediaSession +import android.media.session.PlaybackState +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.ExoPlayer +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.analytics.AnalyticsCollector +import org.jellyfin.mobile.player.source.JellyfinMediaSource +import org.jellyfin.mobile.utils.extensions.width +import com.google.android.exoplayer2.audio.AudioAttributes as ExoPlayerAudioAttributes + +inline fun MediaSession.applyDefaultLocalAudioAttributes(contentType: Int) { + val audioAttributes = AudioAttributes.Builder().apply { + setUsage(AudioAttributes.USAGE_MEDIA) + setContentType(contentType) + if (AndroidVersion.isAtLeastQ) { + setAllowedCapturePolicy(AudioAttributes.ALLOW_CAPTURE_BY_ALL) + } + }.build() + setPlaybackToLocal(audioAttributes) +} + +fun JellyfinMediaSource.toMediaMetadata(): MediaMetadata = MediaMetadata.Builder().apply { + putString(MediaMetadata.METADATA_KEY_MEDIA_ID, itemId.toString()) + putString(MediaMetadata.METADATA_KEY_TITLE, name) + putLong(MediaMetadata.METADATA_KEY_DURATION, runTimeMs) +}.build() + +fun MediaSession.setPlaybackState(playbackState: Int, position: Long, playbackActions: Long) { + val state = PlaybackState.Builder().apply { + setState(playbackState, position, 1.0f) + setActions(playbackActions) + }.build() + setPlaybackState(state) +} + +fun MediaSession.setPlaybackState(isPlaying: Boolean, position: Long, playbackActions: Long) { + setPlaybackState( + if (isPlaying) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED, + position, + playbackActions, + ) +} + +fun MediaSession.setPlaybackState(player: Player, playbackActions: Long) { + val playbackState = when (val playerState = player.playbackState) { + Player.STATE_IDLE, Player.STATE_ENDED -> PlaybackState.STATE_NONE + Player.STATE_READY -> if (player.isPlaying) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED + Player.STATE_BUFFERING -> PlaybackState.STATE_BUFFERING + else -> error("Invalid player playbackState $playerState") + } + setPlaybackState(playbackState, player.currentPosition, playbackActions) +} + +fun AudioManager.getVolumeRange(streamType: Int): IntRange { + val minVolume = (if (AndroidVersion.isAtLeastP) getStreamMinVolume(streamType) else 0) + val maxVolume = getStreamMaxVolume(streamType) + return minVolume..maxVolume +} + +fun AudioManager.getVolumeLevelPercent(): Int { + val stream = AudioManager.STREAM_MUSIC + val volumeRange = getVolumeRange(stream) + val currentVolume = getStreamVolume(stream) + return (currentVolume - volumeRange.first) * Constants.PERCENT_MAX / volumeRange.width +} + +/** + * Set ExoPlayer [ExoPlayerAudioAttributes], make ExoPlayer handle audio focus + */ +inline fun ExoPlayer.applyDefaultAudioAttributes(@C.AudioContentType contentType: Int) { + val audioAttributes = ExoPlayerAudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(contentType) + .build() + setAudioAttributes(audioAttributes, true) +} + +fun Player.seekToOffset(offsetMs: Long) { + var positionMs = currentPosition + offsetMs + val durationMs = duration + if (durationMs != C.TIME_UNSET) { + positionMs = positionMs.coerceAtMost(durationMs) + } + positionMs = positionMs.coerceAtLeast(0) + seekTo(positionMs) +} + +fun Player.logTracks(analyticsCollector: AnalyticsCollector) { + analyticsCollector.onTracksChanged(currentTracks) +} diff --git a/app/src/main/java/org/jellyfin/mobile/utils/PermissionRequestHelper.kt b/app/src/main/java/org/jellyfin/mobile/utils/PermissionRequestHelper.kt new file mode 100644 index 0000000..1a5a4d6 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/PermissionRequestHelper.kt @@ -0,0 +1,55 @@ +package org.jellyfin.mobile.utils + +import android.app.Activity +import android.content.pm.PackageManager +import android.util.SparseArray +import androidx.core.app.ActivityCompat +import org.koin.android.ext.android.getKoin +import java.util.concurrent.atomic.AtomicInteger + +class PermissionRequestHelper { + private val permissionRequests: SparseArray = SparseArray() + + @Suppress("MagicNumber") + private var requestCode = AtomicInteger(50000) // start at a high number to prevent collisions + + fun getRequestCode() = requestCode.getAndIncrement() + + fun addCallback(requestCode: Int, callback: PermissionRequestCallback) { + permissionRequests.put(requestCode, callback) + } + + fun handleRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + // Change to a map + val permissionsMap = permissions + .mapIndexed { index, permission -> + Pair(permission, grantResults.getOrElse(index) { PackageManager.PERMISSION_DENIED }) + } + .toMap() + + // Execute and remove if it exists + permissionRequests[requestCode]?.invoke(permissionsMap) + permissionRequests.delete(requestCode) + } +} + +typealias PermissionRequestCallback = (Map) -> Unit + +fun Activity.requestPermission(vararg permissions: String, callback: PermissionRequestCallback) { + val skipRequest = permissions.all { permission -> + ActivityCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED + } + + if (skipRequest) { + callback(permissions.associateWith { PackageManager.PERMISSION_GRANTED }) + } else { + val helper = getKoin().get() + val code = helper.getRequestCode() + helper.addCallback(code, callback) + ActivityCompat.requestPermissions(this, permissions, code) + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/utils/SmartOrientationListener.kt b/app/src/main/java/org/jellyfin/mobile/utils/SmartOrientationListener.kt new file mode 100644 index 0000000..ff679a9 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/SmartOrientationListener.kt @@ -0,0 +1,26 @@ +package org.jellyfin.mobile.utils + +import android.app.Activity +import android.content.pm.ActivityInfo +import android.view.OrientationEventListener + +/** + * Listener that watches the current device orientation. + * It makes sure that the orientation sensor can still be used (if enabled) + * after toggling the orientation manually. + */ +class SmartOrientationListener(private val activity: Activity) : OrientationEventListener(activity) { + override fun onOrientationChanged(orientation: Int) { + if (!activity.isAutoRotateOn()) return + + val isAtTarget = when (activity.requestedOrientation) { + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT -> orientation in Constants.ORIENTATION_PORTRAIT_RANGE + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE -> orientation in Constants.ORIENTATION_LANDSCAPE_RANGE + else -> false + } + if (isAtTarget) { + // Reset to unspecified orientation + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt b/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt new file mode 100644 index 0000000..ee450d4 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt @@ -0,0 +1,167 @@ +package org.jellyfin.mobile.utils + +import android.Manifest.permission.WRITE_EXTERNAL_STORAGE +import android.app.Activity +import android.app.ActivityManager +import android.app.AlertDialog +import android.app.DownloadManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.PackageManager.PERMISSION_GRANTED +import android.net.Uri +import android.os.Environment +import android.os.PowerManager +import android.provider.Settings +import android.provider.Settings.System.ACCELEROMETER_ROTATION +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.content.getSystemService +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeout +import org.jellyfin.mobile.BuildConfig +import org.jellyfin.mobile.MainActivity +import org.jellyfin.mobile.R +import org.jellyfin.mobile.app.AppPreferences +import org.jellyfin.mobile.settings.ExternalPlayerPackage +import org.jellyfin.mobile.webapp.WebViewFragment +import org.koin.android.ext.android.get +import timber.log.Timber +import java.io.File +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +fun WebViewFragment.requestNoBatteryOptimizations(rootView: CoordinatorLayout) { + if (AndroidVersion.isAtLeastM) { + val powerManager: PowerManager = requireContext().getSystemService(Activity.POWER_SERVICE) as PowerManager + if ( + !appPreferences.ignoreBatteryOptimizations && + !powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) + ) { + Snackbar.make(rootView, R.string.battery_optimizations_message, Snackbar.LENGTH_INDEFINITE).apply { + setAction(android.R.string.ok) { + try { + val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) + startActivity(intent) + } catch (e: ActivityNotFoundException) { + Timber.e(e) + } + + // Ignore after the user interacted with the snackbar at least once + appPreferences.ignoreBatteryOptimizations = true + } + show() + } + } + } +} + +suspend fun MainActivity.requestDownload(uri: Uri, title: String, filename: String) { + val appPreferences: AppPreferences = get() + + // Storage permission for downloads isn't necessary from Android 10 onwards + if (!AndroidVersion.isAtLeastQ) { + @Suppress("MagicNumber") + val granted = withTimeout(2 * 60 * 1000 /* 2 minutes */) { + suspendCoroutine { continuation -> + requestPermission(WRITE_EXTERNAL_STORAGE) { requestPermissionsResult -> + continuation.resume(requestPermissionsResult[WRITE_EXTERNAL_STORAGE] == PERMISSION_GRANTED) + } + } + } + + if (!granted) { + toast(R.string.download_no_storage_permission) + return + } + } + + val downloadMethod = appPreferences.downloadMethod ?: suspendCancellableCoroutine { continuation -> + AlertDialog.Builder(this) + .setTitle(R.string.network_title) + .setMessage(R.string.network_message) + .setPositiveButton(R.string.wifi_only) { _, _ -> + val selectedDownloadMethod = DownloadMethod.WIFI_ONLY + appPreferences.downloadMethod = selectedDownloadMethod + continuation.resume(selectedDownloadMethod) + } + .setNegativeButton(R.string.mobile_data) { _, _ -> + val selectedDownloadMethod = DownloadMethod.MOBILE_DATA + appPreferences.downloadMethod = selectedDownloadMethod + continuation.resume(selectedDownloadMethod) + } + .setNeutralButton(R.string.mobile_data_and_roaming) { _, _ -> + val selectedDownloadMethod = DownloadMethod.MOBILE_AND_ROAMING + appPreferences.downloadMethod = selectedDownloadMethod + continuation.resume(selectedDownloadMethod) + } + .setOnDismissListener { + continuation.cancel(null) + } + .setCancelable(false) + .show() + } + + val downloadRequest = DownloadManager.Request(uri) + .setTitle(title) + .setDescription(getString(R.string.downloading)) + .setDestinationUri(Uri.fromFile(File(appPreferences.downloadLocation, filename))) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + + downloadFile(downloadRequest, downloadMethod) +} + +private fun Context.downloadFile(request: DownloadManager.Request, @DownloadMethod downloadMethod: Int) { + require(downloadMethod >= 0) { "Download method hasn't been set" } + request.apply { + setAllowedOverMetered(downloadMethod >= DownloadMethod.MOBILE_DATA) + setAllowedOverRoaming(downloadMethod == DownloadMethod.MOBILE_AND_ROAMING) + } + getSystemService()?.enqueue(request) +} + +fun Activity.isAutoRotateOn() = Settings.System.getInt(contentResolver, ACCELEROMETER_ROTATION, 0) == 1 + +fun PackageManager.isPackageInstalled(@ExternalPlayerPackage packageName: String) = try { + packageName.isNotEmpty() && getApplicationInfo(packageName, 0).enabled +} catch (e: PackageManager.NameNotFoundException) { + false +} + +fun Context.createMediaNotificationChannel(notificationManager: NotificationManager) { + if (AndroidVersion.isAtLeastO) { + val notificationChannel = NotificationChannel( + Constants.MEDIA_NOTIFICATION_CHANNEL_ID, + getString(R.string.app_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Media notifications" + } + notificationManager.createNotificationChannel(notificationChannel) + } +} + +fun Context.getDownloadsPaths(): List = ArrayList().apply { + for (directory in getExternalFilesDirs(null)) { + // Ignore currently unavailable shared storage + if (directory == null) continue + + val path = directory.absolutePath + val androidFolderIndex = path.indexOf("/Android") + if (androidFolderIndex == -1) continue + + val storageDirectory = File(path.substring(0, androidFolderIndex)) + if (storageDirectory.isDirectory) { + add(File(storageDirectory, Environment.DIRECTORY_DOWNLOADS).absolutePath) + } + } + if (isEmpty()) { + add(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath) + } +} + +val Context.isLowRamDevice: Boolean + get() = getSystemService()!!.isLowRamDevice diff --git a/app/src/main/java/org/jellyfin/mobile/utils/TrackSelectionUtils.kt b/app/src/main/java/org/jellyfin/mobile/utils/TrackSelectionUtils.kt new file mode 100644 index 0000000..76aa9db --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/TrackSelectionUtils.kt @@ -0,0 +1,36 @@ +package org.jellyfin.mobile.utils + +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.source.TrackGroup +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector +import com.google.android.exoplayer2.trackselection.TrackSelectionOverride + +/** + * Select the [trackGroup] of the specified [type] and ensure the type is enabled. + * + * @param type One of the TRACK_TYPE_* constants defined in [C]. + * @param trackGroup the [TrackGroup] to select. + */ +fun DefaultTrackSelector.selectTrackByTypeAndGroup(type: Int, trackGroup: TrackGroup): Boolean { + val parameters = with(buildUponParameters()) { + clearOverridesOfType(type) + addOverride(TrackSelectionOverride(trackGroup, 0)) + setTrackTypeDisabled(type, false) + } + setParameters(parameters) + return true +} + +/** + * Clear selection overrides for all renderers of the specified [type] and disable them. + * + * @param type One of the TRACK_TYPE_* constants defined in [C]. + */ +fun DefaultTrackSelector.clearSelectionAndDisableRendererByType(type: Int): Boolean { + val parameters = with(buildUponParameters()) { + clearOverridesOfType(type) + setTrackTypeDisabled(type, true) + } + setParameters(parameters) + return true +} diff --git a/app/src/main/java/org/jellyfin/mobile/utils/UIExtensions.kt b/app/src/main/java/org/jellyfin/mobile/utils/UIExtensions.kt new file mode 100644 index 0000000..21fc9a1 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/UIExtensions.kt @@ -0,0 +1,70 @@ +@file:Suppress("unused", "NOTHING_TO_INLINE") + +package org.jellyfin.mobile.utils + +import android.content.Context +import android.content.res.Resources +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup.MarginLayoutParams +import android.view.Window +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.annotation.StyleRes +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.interpolator.view.animation.LinearOutSlowInInterpolator +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +inline fun Context.toast(@StringRes text: Int, duration: Int = Toast.LENGTH_SHORT) = + Toast.makeText(this, text, duration).show() + +inline fun Context.toast(text: CharSequence, duration: Int = Toast.LENGTH_SHORT) = + Toast.makeText(this, text, duration).show() + +inline fun LifecycleOwner.runOnUiThread(noinline block: suspend CoroutineScope.() -> Unit) { + lifecycleScope.launch(Dispatchers.Main, block = block) +} + +fun LayoutInflater.withThemedContext(context: Context, @StyleRes style: Int): LayoutInflater { + return cloneInContext(ContextThemeWrapper(context, style)) +} + +fun View.applyWindowInsetsAsMargins() { + ViewCompat.setOnApplyWindowInsetsListener(this) { _, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + updateLayoutParams { + setMargins(insets.left, insets.top, insets.right, insets.bottom) + } + windowInsets + } +} + +fun View.fadeIn() { + alpha = 0f + isVisible = true + animate().apply { + alpha(1f) + @Suppress("MagicNumber") + duration = 300L + interpolator = LinearOutSlowInInterpolator() + withLayer() + } +} + +inline fun Resources.dip(px: Int) = (px * displayMetrics.density).toInt() + +inline var Window.brightness: Float + get() = attributes.screenBrightness + set(value) { + attributes = attributes.apply { + screenBrightness = value + } + } diff --git a/app/src/main/java/org/jellyfin/mobile/utils/WebViewUtils.kt b/app/src/main/java/org/jellyfin/mobile/utils/WebViewUtils.kt new file mode 100644 index 0000000..c659f80 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/WebViewUtils.kt @@ -0,0 +1,120 @@ +/** + * Taken and adapted from https://github.com/tachiyomiorg/tachiyomi/blob/master/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt + * + * Copyright 2015 Javier Tomás + * + * 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 org.jellyfin.mobile.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager +import android.webkit.CookieManager +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebView +import androidx.webkit.ServiceWorkerClientCompat +import androidx.webkit.ServiceWorkerControllerCompat +import androidx.webkit.WebViewAssetLoader +import androidx.webkit.WebViewFeature +import io.ktor.http.HttpStatusCode +import timber.log.Timber +import java.util.Locale + +fun Context.isWebViewSupported(): Boolean { + @Suppress("TooGenericExceptionCaught") + try { + // May throw android.webkit.WebViewFactory$MissingWebViewPackageException if WebView is not installed + CookieManager.getInstance() + } catch (e: Exception) { + Timber.e(e) + return false + } + + return packageManager.hasSystemFeature(PackageManager.FEATURE_WEBVIEW) +} + +fun WebView.isOutdated(): Boolean = + getWebViewMajorVersion() < Constants.MINIMUM_WEB_VIEW_VERSION + +private fun WebView.getWebViewMajorVersion(): Int { + val userAgent = getDefaultUserAgentString() + val version = """.*Chrome/(\d+)\..*""".toRegex().matchEntire(userAgent)?.let { match -> + match.groupValues.getOrNull(1)?.toInt() + } ?: 0 + + Timber.i("WebView user agent is $userAgent, detected version is $version") + + return version +} + +// Based on https://stackoverflow.com/a/29218966 +private fun WebView.getDefaultUserAgentString(): String { + val originalUA: String = settings.userAgentString + + // Next call to getUserAgentString() will get us the default + settings.userAgentString = null + val defaultUserAgentString = settings.userAgentString + + // Revert to original UA string + settings.userAgentString = originalUA + + return defaultUserAgentString +} + +/** + * Workaround for service worker breaking script injections + */ +fun enableServiceWorkerWorkaround() { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.SERVICE_WORKER_BASIC_USAGE)) { + return + } + + val serviceWorkerClient = object : ServiceWorkerClientCompat() { + override fun shouldInterceptRequest(request: WebResourceRequest): WebResourceResponse? { + val path = request.url.path?.lowercase(Locale.ROOT) ?: return null + return when { + path.endsWith(Constants.SERVICE_WORKER_PATH) -> { + WebResourceResponse("application/javascript", "utf-8", null).apply { + with(HttpStatusCode.NotFound) { setStatusCodeAndReasonPhrase(value, description) } + } + } + else -> null + } + } + } + + ServiceWorkerControllerCompat.getInstance().setServiceWorkerClient(serviceWorkerClient) +} + +@SuppressLint("SetJavaScriptEnabled") +fun WebSettings.applyDefault() { + javaScriptEnabled = true + domStorageEnabled = true +} + +/** + * Opens the requested file from the application's assets directory. + * + * On some devices Android doesn't set the JavaScript MIME type, + * thus manually set it to "application/javascript" where applicable. + * + * @see WebViewAssetLoader.AssetsPathHandler.handle + */ +fun WebViewAssetLoader.AssetsPathHandler.inject(path: String): WebResourceResponse? = handle(path)?.apply { + if (path.endsWith(".js", ignoreCase = true)) { + mimeType = "application/javascript" + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/utils/extensions/Activity.kt b/app/src/main/java/org/jellyfin/mobile/utils/extensions/Activity.kt new file mode 100644 index 0000000..f7833fa --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/extensions/Activity.kt @@ -0,0 +1,20 @@ +package org.jellyfin.mobile.utils.extensions + +import android.app.Activity +import android.content.pm.ActivityInfo +import android.graphics.Point +import android.view.Surface + +@Suppress("DEPRECATION") +fun Activity.lockOrientation() { + val display = windowManager.defaultDisplay + val size = Point().also(display::getSize) + val height = size.y + val width = size.x + requestedOrientation = when (display.rotation) { + Surface.ROTATION_90 -> if (width > height) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE else ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT + Surface.ROTATION_180 -> if (height > width) ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT else ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE + Surface.ROTATION_270 -> if (width > height) ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE else ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + else -> if (height > width) ActivityInfo.SCREEN_ORIENTATION_PORTRAIT else ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/utils/extensions/Bundle.kt b/app/src/main/java/org/jellyfin/mobile/utils/extensions/Bundle.kt new file mode 100644 index 0000000..bff89a9 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/extensions/Bundle.kt @@ -0,0 +1,10 @@ +package org.jellyfin.mobile.utils.extensions + +import android.os.Bundle +import org.jellyfin.mobile.utils.AndroidVersion + +@Suppress("DEPRECATION") +inline fun Bundle.getParcelableCompat(key: String?): T? = when { + AndroidVersion.isAtLeastT -> getParcelable(key, T::class.java) + else -> getParcelable(key) +} diff --git a/app/src/main/java/org/jellyfin/mobile/utils/extensions/Fragment.kt b/app/src/main/java/org/jellyfin/mobile/utils/extensions/Fragment.kt new file mode 100644 index 0000000..2c3eb46 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/extensions/Fragment.kt @@ -0,0 +1,8 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package org.jellyfin.mobile.utils.extensions + +import androidx.fragment.app.Fragment +import org.jellyfin.mobile.MainActivity + +inline fun Fragment.requireMainActivity(): MainActivity = requireActivity() as MainActivity diff --git a/app/src/main/java/org/jellyfin/mobile/utils/extensions/FragmentManager.kt b/app/src/main/java/org/jellyfin/mobile/utils/extensions/FragmentManager.kt new file mode 100644 index 0000000..926ff12 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/extensions/FragmentManager.kt @@ -0,0 +1,21 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package org.jellyfin.mobile.utils.extensions + +import android.os.Bundle +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.add +import androidx.fragment.app.replace +import org.jellyfin.mobile.R + +inline fun FragmentManager.addFragment(args: Bundle? = null) { + beginTransaction().apply { + add(R.id.fragment_container, args = args) + addToBackStack(null) + }.commit() +} + +inline fun FragmentManager.replaceFragment(args: Bundle? = null) { + beginTransaction().replace(R.id.fragment_container, args = args).commit() +} diff --git a/app/src/main/java/org/jellyfin/mobile/utils/extensions/Int.kt b/app/src/main/java/org/jellyfin/mobile/utils/extensions/Int.kt new file mode 100644 index 0000000..e602db7 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/extensions/Int.kt @@ -0,0 +1,14 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package org.jellyfin.mobile.utils.extensions + +import androidx.annotation.CheckResult + +@CheckResult +inline fun Int.hasFlag(flag: Int) = this and flag == flag + +@CheckResult +inline fun Int.withFlag(flag: Int) = this or flag + +@CheckResult +inline fun Int.withoutFlag(flag: Int) = this and flag.inv() diff --git a/app/src/main/java/org/jellyfin/mobile/utils/extensions/IntRange.kt b/app/src/main/java/org/jellyfin/mobile/utils/extensions/IntRange.kt new file mode 100644 index 0000000..daf5c48 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/extensions/IntRange.kt @@ -0,0 +1,15 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package org.jellyfin.mobile.utils.extensions + +import androidx.annotation.CheckResult +import org.jellyfin.mobile.utils.Constants + +@get:CheckResult +val IntRange.width: Int + get() = endInclusive - start + +@CheckResult +fun IntRange.scaleInRange(percent: Int): Int { + return start + width * percent / Constants.PERCENT_MAX +} diff --git a/app/src/main/java/org/jellyfin/mobile/utils/extensions/JSONArray.kt b/app/src/main/java/org/jellyfin/mobile/utils/extensions/JSONArray.kt new file mode 100644 index 0000000..ce6b01a --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/extensions/JSONArray.kt @@ -0,0 +1,5 @@ +package org.jellyfin.mobile.utils.extensions + +import org.json.JSONArray + +val JSONArray.size: Int get() = length() diff --git a/app/src/main/java/org/jellyfin/mobile/utils/extensions/MediaMetadataCompat.kt b/app/src/main/java/org/jellyfin/mobile/utils/extensions/MediaMetadataCompat.kt new file mode 100644 index 0000000..7e43eaf --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/extensions/MediaMetadataCompat.kt @@ -0,0 +1,122 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package org.jellyfin.mobile.utils.extensions + +import android.graphics.Bitmap +import android.net.Uri +import android.support.v4.media.MediaMetadataCompat +import androidx.core.net.toUri + +inline val MediaMetadataCompat.mediaId: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID) + +inline val MediaMetadataCompat.title: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_TITLE) + +inline val MediaMetadataCompat.artist: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_ARTIST) + +inline val MediaMetadataCompat.duration + get() = getLong(MediaMetadataCompat.METADATA_KEY_DURATION) + +inline val MediaMetadataCompat.album: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_ALBUM) + +inline val MediaMetadataCompat.author: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_AUTHOR) + +inline val MediaMetadataCompat.writer: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_WRITER) + +inline val MediaMetadataCompat.composer: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_COMPOSER) + +inline val MediaMetadataCompat.compilation: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_COMPILATION) + +inline val MediaMetadataCompat.date: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_DATE) + +inline val MediaMetadataCompat.year: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_YEAR) + +inline val MediaMetadataCompat.genre: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_GENRE) + +inline val MediaMetadataCompat.trackNumber + get() = getLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER) + +inline val MediaMetadataCompat.trackCount + get() = getLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS) + +inline val MediaMetadataCompat.discNumber + get() = getLong(MediaMetadataCompat.METADATA_KEY_DISC_NUMBER) + +inline val MediaMetadataCompat.albumArtist: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST) + +inline val MediaMetadataCompat.art: Bitmap + get() = getBitmap(MediaMetadataCompat.METADATA_KEY_ART) + +inline val MediaMetadataCompat.artUri: Uri + get() = this.getString(MediaMetadataCompat.METADATA_KEY_ART_URI).toUri() + +inline val MediaMetadataCompat.albumArt: Bitmap? + get() = getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART) + +inline val MediaMetadataCompat.albumArtUri: Uri + get() = this.getString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI).toUri() + +inline val MediaMetadataCompat.userRating + get() = getLong(MediaMetadataCompat.METADATA_KEY_USER_RATING) + +inline val MediaMetadataCompat.rating + get() = getLong(MediaMetadataCompat.METADATA_KEY_RATING) + +inline val MediaMetadataCompat.displayTitle: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE) + +inline val MediaMetadataCompat.displaySubtitle: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE) + +inline val MediaMetadataCompat.displayDescription: String? + get() = getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION) + +inline val MediaMetadataCompat.displayIcon: Bitmap + get() = getBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON) + +inline val MediaMetadataCompat.displayIconUri: Uri + get() = this.getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI).toUri() + +inline val MediaMetadataCompat.mediaUri: Uri + get() = this.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI).toUri() + +inline val MediaMetadataCompat.downloadStatus + get() = getLong(MediaMetadataCompat.METADATA_KEY_DOWNLOAD_STATUS) + +inline fun MediaMetadataCompat.Builder.setMediaId(id: String): MediaMetadataCompat.Builder = + putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) + +inline fun MediaMetadataCompat.Builder.setTitle(title: String): MediaMetadataCompat.Builder = + putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) + +inline fun MediaMetadataCompat.Builder.setAlbum(album: String): MediaMetadataCompat.Builder = + putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album) + +inline fun MediaMetadataCompat.Builder.setArtist(artist: String): MediaMetadataCompat.Builder = + putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) + +inline fun MediaMetadataCompat.Builder.setAlbumArtist(artist: String): MediaMetadataCompat.Builder = + putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, artist) + +inline fun MediaMetadataCompat.Builder.setTrackNumber(number: Long): MediaMetadataCompat.Builder = + putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, number) + +inline fun MediaMetadataCompat.Builder.setMediaUri(uri: String): MediaMetadataCompat.Builder = + putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, uri) + +inline fun MediaMetadataCompat.Builder.setAlbumArtUri(uri: String): MediaMetadataCompat.Builder = + putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, uri) + +inline fun MediaMetadataCompat.Builder.setDisplayIconUri(uri: String): MediaMetadataCompat.Builder = + putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, uri) diff --git a/app/src/main/java/org/jellyfin/mobile/utils/extensions/MediaStream.kt b/app/src/main/java/org/jellyfin/mobile/utils/extensions/MediaStream.kt new file mode 100644 index 0000000..ca71bed --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/extensions/MediaStream.kt @@ -0,0 +1,10 @@ +package org.jellyfin.mobile.utils.extensions + +import android.util.Rational +import org.jellyfin.sdk.model.api.MediaStream + +val MediaStream.isLandscape: Boolean + get() = if (width != null && height != null) width!! >= height!! else true + +val MediaStream.aspectRational: Rational? + get() = if (width != null && height != null) Rational(width!!, height!!) else null diff --git a/app/src/main/java/org/jellyfin/mobile/utils/extensions/Window.kt b/app/src/main/java/org/jellyfin/mobile/utils/extensions/Window.kt new file mode 100644 index 0000000..a1568d3 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/extensions/Window.kt @@ -0,0 +1,8 @@ +package org.jellyfin.mobile.utils.extensions + +import android.view.Window +import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + +inline var Window.keepScreenOn: Boolean + get() = attributes.flags.hasFlag(FLAG_KEEP_SCREEN_ON) + set(value) = if (value) addFlags(FLAG_KEEP_SCREEN_ON) else clearFlags(FLAG_KEEP_SCREEN_ON) diff --git a/app/src/main/java/org/jellyfin/mobile/webapp/JellyfinWebChromeClient.kt b/app/src/main/java/org/jellyfin/mobile/webapp/JellyfinWebChromeClient.kt new file mode 100644 index 0000000..17d7245 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/webapp/JellyfinWebChromeClient.kt @@ -0,0 +1,52 @@ +package org.jellyfin.mobile.webapp + +import android.content.Intent +import android.net.Uri +import android.util.Log +import android.webkit.ConsoleMessage +import android.webkit.ValueCallback +import android.webkit.WebChromeClient +import android.webkit.WebView +import timber.log.Timber + +class JellyfinWebChromeClient( + private val fileChooserListener: FileChooserListener, +) : WebChromeClient() { + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { + val logLevel = when (consoleMessage.messageLevel()) { + ConsoleMessage.MessageLevel.ERROR -> Log.ERROR + ConsoleMessage.MessageLevel.WARNING -> Log.WARN + ConsoleMessage.MessageLevel.DEBUG -> Log.DEBUG + ConsoleMessage.MessageLevel.TIP -> Log.VERBOSE + else -> Log.INFO + } + + Timber.tag("WebView").log( + logLevel, + "%s, %s (%d)", + consoleMessage.message(), + consoleMessage.sourceId(), + consoleMessage.lineNumber(), + ) + + return true + } + + override fun onShowFileChooser( + webView: WebView, + filePathCallback: ValueCallback>, + fileChooserParams: FileChooserParams?, + ): Boolean { + if (fileChooserParams == null) { + filePathCallback.onReceiveValue(null) + return true + } + + fileChooserListener.onShowFileChooser(fileChooserParams.createIntent(), filePathCallback) + return true + } + + interface FileChooserListener { + fun onShowFileChooser(intent: Intent, filePathCallback: ValueCallback>) + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/webapp/JellyfinWebViewClient.kt b/app/src/main/java/org/jellyfin/mobile/webapp/JellyfinWebViewClient.kt new file mode 100644 index 0000000..9ad14a3 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/webapp/JellyfinWebViewClient.kt @@ -0,0 +1,104 @@ +package org.jellyfin.mobile.webapp + +import android.net.http.SslError +import android.webkit.SslErrorHandler +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import androidx.webkit.WebResourceErrorCompat +import androidx.webkit.WebViewAssetLoader.AssetsPathHandler +import androidx.webkit.WebViewClientCompat +import androidx.webkit.WebViewFeature +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.jellyfin.mobile.app.ApiClientController +import org.jellyfin.mobile.data.entity.ServerEntity +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.utils.initLocale +import org.jellyfin.mobile.utils.inject +import org.json.JSONException +import org.json.JSONObject +import timber.log.Timber +import java.io.Reader +import java.util.Locale +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +abstract class JellyfinWebViewClient( + private val coroutineScope: CoroutineScope, + private val server: ServerEntity, + private val assetsPathHandler: AssetsPathHandler, + private val apiClientController: ApiClientController, +) : WebViewClientCompat() { + + abstract fun onConnectedToWebapp() + + abstract fun onErrorReceived() + + override fun shouldInterceptRequest(webView: WebView, request: WebResourceRequest): WebResourceResponse? { + val url = request.url + val path = url.path?.lowercase(Locale.ROOT) ?: return null + return when { + path.matches(Constants.MAIN_BUNDLE_PATH_REGEX) && "deferred" !in url.query.orEmpty() -> { + onConnectedToWebapp() + assetsPathHandler.inject("native/injectionScript.js") + } + // Load injected scripts from application assets + path.contains("/native/") -> assetsPathHandler.inject("native/${url.lastPathSegment}") + // Load the chrome.cast.js library instead + path.endsWith(Constants.CAST_SDK_PATH) -> assetsPathHandler.inject("native/chrome.cast.js") + path.endsWith(Constants.SESSION_CAPABILITIES_PATH) -> { + coroutineScope.launch { + val credentials = suspendCoroutine { continuation -> + webView.evaluateJavascript("JSON.parse(window.localStorage.getItem('jellyfin_credentials'))") { result -> + try { + continuation.resume(JSONObject(result)) + } catch (e: JSONException) { + val message = "Failed to extract credentials" + Timber.e(e, message) + continuation.resumeWithException(Exception(message, e)) + } + } + } + val storedServer = credentials.getJSONArray("Servers").getJSONObject(0) + val user = storedServer.getString("UserId") + val token = storedServer.getString("AccessToken") + apiClientController.setupUser(server.id, user, token) + webView.initLocale(user) + } + null + } + else -> null + } + } + + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse, + ) { + val errorMessage = errorResponse.data?.run { bufferedReader().use(Reader::readText) } + Timber.e("Received WebView HTTP %d error: %s", errorResponse.statusCode, errorMessage) + + if (request.isForMainFrame) onErrorReceived() + } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceErrorCompat, + ) { + val description = if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_RESOURCE_ERROR_GET_DESCRIPTION)) error.description else null + val errorCode = if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_RESOURCE_ERROR_GET_CODE)) error.errorCode else ERROR_UNKNOWN + Timber.e("Received WebView error %d at %s: %s", errorCode, request.url.toString(), description) + + // Abort on some specific error codes or when the request url matches the server url + if (request.isForMainFrame) onErrorReceived() + } + + override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) { + Timber.e("Received SSL error: %s", error.toString()) + handler.cancel() + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/webapp/RemotePlayerService.kt b/app/src/main/java/org/jellyfin/mobile/webapp/RemotePlayerService.kt new file mode 100644 index 0000000..f156aee --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/webapp/RemotePlayerService.kt @@ -0,0 +1,473 @@ +package org.jellyfin.mobile.webapp + +import android.annotation.SuppressLint +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.bluetooth.BluetoothA2dp +import android.bluetooth.BluetoothHeadset +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ServiceInfo +import android.graphics.Bitmap +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.MediaMetadata +import android.media.session.MediaController +import android.media.session.MediaSession +import android.media.session.PlaybackState +import android.os.Binder +import android.os.IBinder +import android.os.PowerManager +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.getSystemService +import androidx.core.graphics.drawable.toBitmap +import androidx.core.text.HtmlCompat +import coil.ImageLoader +import coil.request.ImageRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.jellyfin.mobile.MainActivity +import org.jellyfin.mobile.R +import org.jellyfin.mobile.app.AppPreferences +import org.jellyfin.mobile.utils.AndroidVersion +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.utils.Constants.EXTRA_ALBUM +import org.jellyfin.mobile.utils.Constants.EXTRA_ARTIST +import org.jellyfin.mobile.utils.Constants.EXTRA_CAN_SEEK +import org.jellyfin.mobile.utils.Constants.EXTRA_DURATION +import org.jellyfin.mobile.utils.Constants.EXTRA_IMAGE_URL +import org.jellyfin.mobile.utils.Constants.EXTRA_IS_LOCAL_PLAYER +import org.jellyfin.mobile.utils.Constants.EXTRA_IS_PAUSED +import org.jellyfin.mobile.utils.Constants.EXTRA_ITEM_ID +import org.jellyfin.mobile.utils.Constants.EXTRA_PLAYER_ACTION +import org.jellyfin.mobile.utils.Constants.EXTRA_POSITION +import org.jellyfin.mobile.utils.Constants.EXTRA_TITLE +import org.jellyfin.mobile.utils.Constants.MEDIA_NOTIFICATION_CHANNEL_ID +import org.jellyfin.mobile.utils.Constants.MEDIA_PLAYER_NOTIFICATION_ID +import org.jellyfin.mobile.utils.Constants.PLAYBACK_MANAGER_COMMAND_FAST_FORWARD +import org.jellyfin.mobile.utils.Constants.PLAYBACK_MANAGER_COMMAND_NEXT +import org.jellyfin.mobile.utils.Constants.PLAYBACK_MANAGER_COMMAND_PAUSE +import org.jellyfin.mobile.utils.Constants.PLAYBACK_MANAGER_COMMAND_PLAY +import org.jellyfin.mobile.utils.Constants.PLAYBACK_MANAGER_COMMAND_PREVIOUS +import org.jellyfin.mobile.utils.Constants.PLAYBACK_MANAGER_COMMAND_REWIND +import org.jellyfin.mobile.utils.Constants.PLAYBACK_MANAGER_COMMAND_STOP +import org.jellyfin.mobile.utils.Constants.SUPPORTED_MUSIC_PLAYER_PLAYBACK_ACTIONS +import org.jellyfin.mobile.utils.applyDefaultLocalAudioAttributes +import org.jellyfin.mobile.utils.createMediaNotificationChannel +import org.jellyfin.mobile.utils.setPlaybackState +import org.koin.android.ext.android.inject +import kotlin.coroutines.CoroutineContext + +class RemotePlayerService : Service(), CoroutineScope { + + private lateinit var job: Job + override val coroutineContext: CoroutineContext + get() = job + Dispatchers.Main + + private val appPreferences: AppPreferences by inject() + private val notificationManager: NotificationManager by lazy { getSystemService()!! } + private val imageLoader: ImageLoader by inject() + + private val binder = ServiceBinder(this) + private val webappFunctionChannel: WebappFunctionChannel by inject() + private val remoteVolumeProvider: RemoteVolumeProvider by inject() + private lateinit var wakeLock: PowerManager.WakeLock + + private var mediaSession: MediaSession? = null + private var mediaController: MediaController? = null + private var largeItemIcon: Bitmap? = null + private var currentItemId: String? = null + + val playbackState: PlaybackState? get() = mediaSession?.controller?.playbackState + + private val receiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + AudioManager.ACTION_HEADSET_PLUG -> { + val state = intent.getIntExtra("state", 0) + // Pause playback when unplugging headphones + if (state == 0) webappFunctionChannel.callPlaybackManagerAction(PLAYBACK_MANAGER_COMMAND_PAUSE) + } + BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED -> { + val extras = intent.extras ?: return + val state = extras.getInt(BluetoothA2dp.EXTRA_STATE) + val previousState = extras.getInt(BluetoothA2dp.EXTRA_PREVIOUS_STATE) + if ((state == BluetoothA2dp.STATE_DISCONNECTED || state == BluetoothA2dp.STATE_DISCONNECTING) && previousState == BluetoothA2dp.STATE_CONNECTED) { + webappFunctionChannel.callPlaybackManagerAction(PLAYBACK_MANAGER_COMMAND_PAUSE) + } + } + BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED -> { + val extras = intent.extras ?: return + val state = extras.getInt(BluetoothHeadset.EXTRA_STATE) + val previousState = extras.getInt(BluetoothHeadset.EXTRA_PREVIOUS_STATE) + if (state == BluetoothHeadset.STATE_AUDIO_DISCONNECTED && previousState == BluetoothHeadset.STATE_AUDIO_CONNECTED) { + webappFunctionChannel.callPlaybackManagerAction(PLAYBACK_MANAGER_COMMAND_PAUSE) + } + } + } + } + } + + override fun onCreate() { + super.onCreate() + job = Job() + + // Create wakelock for the service + val powerManager: PowerManager = getSystemService(AppCompatActivity.POWER_SERVICE) as PowerManager + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "jellyfin:WakeLock") + wakeLock.setReferenceCounted(false) + + // Add intent filter to watch for headphone state + val filter = IntentFilter().apply { + addAction(Intent.ACTION_HEADSET_PLUG) + + // Bluetooth related filters - needs BLUETOOTH permission + addAction(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED) + addAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED) + } + registerReceiver(receiver, filter) + + // Create notification channel + createMediaNotificationChannel(notificationManager) + } + + override fun onBind(intent: Intent): IBinder { + return binder + } + + override fun onUnbind(intent: Intent): Boolean { + onStopped() + return super.onUnbind(intent) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (mediaSession == null) { + initMediaSession() + } + handleIntent(intent) + return super.onStartCommand(intent, flags, startId) + } + + private fun startWakelock() { + if (!wakeLock.isHeld) { + @Suppress("MagicNumber") + wakeLock.acquire(4 * 60 * 60 * 1000L /* 4 hours */) + } + } + + private fun stopWakelock() { + if (wakeLock.isHeld) wakeLock.release() + } + + private fun handleIntent(intent: Intent?) { + if (intent?.action == null) { + return + } + val action = intent.action + if (action == Constants.ACTION_REPORT) { + notify(intent) + return + } + val transportControls = mediaController?.transportControls ?: return + when (action) { + Constants.ACTION_PLAY -> { + transportControls.play() + startWakelock() + } + Constants.ACTION_PAUSE -> { + transportControls.pause() + stopWakelock() + } + Constants.ACTION_FAST_FORWARD -> transportControls.fastForward() + Constants.ACTION_REWIND -> transportControls.rewind() + Constants.ACTION_PREVIOUS -> transportControls.skipToPrevious() + Constants.ACTION_NEXT -> transportControls.skipToNext() + Constants.ACTION_STOP -> transportControls.stop() + } + } + + @Suppress("ComplexMethod", "LongMethod") + private fun notify(handledIntent: Intent) { + if (handledIntent.getStringExtra(EXTRA_PLAYER_ACTION) == "playbackstop") { + onStopped() + return + } + + launch { + val mediaSession = mediaSession!! + + val itemId = handledIntent.getStringExtra(EXTRA_ITEM_ID) ?: return@launch + val title = handledIntent.getStringExtra(EXTRA_TITLE) + val artist = handledIntent.getStringExtra(EXTRA_ARTIST) + val album = handledIntent.getStringExtra(EXTRA_ALBUM) + val imageUrl = handledIntent.getStringExtra(EXTRA_IMAGE_URL) + val position = handledIntent.getLongExtra(EXTRA_POSITION, PlaybackState.PLAYBACK_POSITION_UNKNOWN) + val duration = handledIntent.getLongExtra(EXTRA_DURATION, 0) + val canSeek = handledIntent.getBooleanExtra(EXTRA_CAN_SEEK, false) + val isLocalPlayer = handledIntent.getBooleanExtra(EXTRA_IS_LOCAL_PLAYER, true) + val isPaused = handledIntent.getBooleanExtra(EXTRA_IS_PAUSED, false) + + // Resolve notification bitmap + val cachedBitmap = largeItemIcon?.takeIf { itemId == currentItemId } + val bitmap = cachedBitmap ?: if (!imageUrl.isNullOrEmpty()) { + val request = ImageRequest.Builder(this@RemotePlayerService).data(imageUrl).build() + imageLoader.execute(request).drawable?.toBitmap()?.also { bitmap -> + largeItemIcon = bitmap // Cache bitmap for later use + } + } else { + null + } + + // Set/update media metadata if item changed + if (itemId != currentItemId) { + val metadata = MediaMetadata.Builder().apply { + putString(MediaMetadata.METADATA_KEY_MEDIA_ID, itemId) + putString(MediaMetadata.METADATA_KEY_ARTIST, artist) + putString(MediaMetadata.METADATA_KEY_ALBUM, album) + putString(MediaMetadata.METADATA_KEY_TITLE, title) + putLong(MediaMetadata.METADATA_KEY_DURATION, duration) + if (bitmap != null) putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, bitmap) + }.build() + mediaSession.setMetadata(metadata) + currentItemId = itemId + } + + setPlaybackState(!isPaused, position, canSeek) + + if (isLocalPlayer) { + mediaSession.applyDefaultLocalAudioAttributes(AudioAttributes.CONTENT_TYPE_MUSIC) + } else { + mediaSession.setPlaybackToRemote(remoteVolumeProvider) + } + + val supportsNativeSeek = AndroidVersion.isAtLeastQ + + val style = Notification.MediaStyle().apply { + setMediaSession(mediaSession.sessionToken) + @Suppress("MagicNumber") + val compactActions = if (supportsNativeSeek) intArrayOf(0, 1, 2) else intArrayOf(0, 2, 4) + setShowActionsInCompactView(*compactActions) + } + + @Suppress("DEPRECATION") + val notification = Notification.Builder(this@RemotePlayerService).apply { + if (AndroidVersion.isAtLeastO) { + setChannelId(MEDIA_NOTIFICATION_CHANNEL_ID) // Set Notification Channel on Android O and above + setColorized(true) // Color notification based on cover art + } else { + setPriority(Notification.PRIORITY_LOW) + } + + setContentTitle(title?.let { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY) }) + setContentText(artist?.let { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY) }) + setSubText(album) + if (position != PlaybackState.PLAYBACK_POSITION_UNKNOWN) { + if (!AndroidVersion.isAtLeastN) { + // Show current position in "when" field pre-N + setShowWhen(!isPaused) + setUsesChronometer(!isPaused) + setWhen(System.currentTimeMillis() - position) + } + } + setStyle(style) + setVisibility(Notification.VISIBILITY_PUBLIC) // Privacy value for lock screen + setOngoing(!isPaused && !appPreferences.musicNotificationAlwaysDismissible) // Swipe to dismiss if paused + setDeleteIntent(createDeleteIntent()) + setContentIntent(createContentIntent()) + + // Set icons + setSmallIcon(R.drawable.ic_notification) + if (bitmap != null) setLargeIcon(bitmap) + + // Setup actions + addAction( + generateAction( + R.drawable.ic_skip_previous_black_32dp, + R.string.notification_action_previous, + Constants.ACTION_PREVIOUS, + ), + ) + if (!supportsNativeSeek) { + addAction( + generateAction( + R.drawable.ic_rewind_black_32dp, + R.string.notification_action_rewind, + Constants.ACTION_REWIND, + ), + ) + } + val playbackAction = when { + isPaused -> generateAction( + R.drawable.ic_play_black_42dp, + R.string.notification_action_play, + Constants.ACTION_PLAY, + ) + else -> generateAction( + R.drawable.ic_pause_black_42dp, + R.string.notification_action_pause, + Constants.ACTION_PAUSE, + ) + } + addAction(playbackAction) + if (!supportsNativeSeek) { + addAction( + generateAction( + R.drawable.ic_fast_forward_black_32dp, + R.string.notification_action_fast_forward, + Constants.ACTION_FAST_FORWARD, + ), + ) + } + addAction( + generateAction( + R.drawable.ic_skip_next_black_32dp, + R.string.notification_action_next, + Constants.ACTION_NEXT, + ), + ) + addAction( + generateAction( + R.drawable.ic_stop_black_32dp, + R.string.notification_action_stop, + Constants.ACTION_STOP, + ), + ) + }.build() + + // Post notification + if (AndroidVersion.isAtLeastQ) { + startForeground( + MEDIA_PLAYER_NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST, + ) + } else { + startForeground( + MEDIA_PLAYER_NOTIFICATION_ID, + notification, + ) + } + + // Activate MediaSession + mediaSession.isActive = true + } + } + + private fun setPlaybackState(isPlaying: Boolean, position: Long, canSeek: Boolean) { + val playbackActions = when { + canSeek -> SUPPORTED_MUSIC_PLAYER_PLAYBACK_ACTIONS or PlaybackState.ACTION_SEEK_TO + else -> SUPPORTED_MUSIC_PLAYER_PLAYBACK_ACTIONS + } + mediaSession!!.setPlaybackState(isPlaying, position, playbackActions) + } + + private fun createDeleteIntent(): PendingIntent { + val intent = Intent(applicationContext, RemotePlayerService::class.java).apply { + action = Constants.ACTION_STOP + } + return PendingIntent.getService(applicationContext, 1, intent, Constants.PENDING_INTENT_FLAGS) + } + + private fun createContentIntent(): PendingIntent { + val intent = Intent(this, MainActivity::class.java).apply { + action = Constants.ACTION_SHOW_PLAYER + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + } + return PendingIntent.getActivity( + this, + Constants.REMOTE_PLAYER_CONTENT_INTENT_REQUEST_CODE, + intent, + Constants.PENDING_INTENT_FLAGS, + ) + } + + private fun generateAction(icon: Int, @StringRes title: Int, intentAction: String): Notification.Action { + val intent = Intent(applicationContext, RemotePlayerService::class.java).apply { + action = intentAction + } + val pendingIntent = PendingIntent.getService( + applicationContext, + MEDIA_PLAYER_NOTIFICATION_ID, + intent, + Constants.PENDING_INTENT_FLAGS, + ) + @Suppress("DEPRECATION") + return Notification.Action.Builder(icon, getString(title), pendingIntent).build() + } + + private fun initMediaSession() { + mediaSession = MediaSession(applicationContext, javaClass.toString()).apply { + mediaController = MediaController(applicationContext, sessionToken) + @Suppress("DEPRECATION") + setFlags(MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS or MediaSession.FLAG_HANDLES_MEDIA_BUTTONS) + setCallback( + @SuppressLint("MissingOnPlayFromSearch") + object : MediaSession.Callback() { + override fun onPlay() { + webappFunctionChannel.callPlaybackManagerAction(PLAYBACK_MANAGER_COMMAND_PLAY) + } + + override fun onPause() { + webappFunctionChannel.callPlaybackManagerAction(PLAYBACK_MANAGER_COMMAND_PAUSE) + } + + override fun onSkipToPrevious() { + webappFunctionChannel.callPlaybackManagerAction(PLAYBACK_MANAGER_COMMAND_PREVIOUS) + } + + override fun onSkipToNext() { + webappFunctionChannel.callPlaybackManagerAction(PLAYBACK_MANAGER_COMMAND_NEXT) + } + + override fun onRewind() { + webappFunctionChannel.callPlaybackManagerAction(PLAYBACK_MANAGER_COMMAND_REWIND) + } + + override fun onFastForward() { + webappFunctionChannel.callPlaybackManagerAction(PLAYBACK_MANAGER_COMMAND_FAST_FORWARD) + } + + override fun onStop() { + webappFunctionChannel.callPlaybackManagerAction(PLAYBACK_MANAGER_COMMAND_STOP) + onStopped() + } + + override fun onSeekTo(pos: Long) { + webappFunctionChannel.seekTo(pos) + val currentState = playbackState ?: return + val isPlaying = currentState.state == PlaybackState.STATE_PLAYING + val canSeek = (currentState.actions and PlaybackState.ACTION_SEEK_TO) != 0L + setPlaybackState(isPlaying, pos, canSeek) + } + }, + ) + } + } + + private fun onStopped() { + notificationManager.cancel(MEDIA_PLAYER_NOTIFICATION_ID) + mediaSession?.isActive = false + stopWakelock() + stopSelf() + } + + override fun onDestroy() { + unregisterReceiver(receiver) + job.cancel() + mediaSession?.release() + mediaSession = null + super.onDestroy() + } + + class ServiceBinder(private val service: RemotePlayerService) : Binder() { + val isPlaying: Boolean + get() = service.playbackState?.state == PlaybackState.STATE_PLAYING + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/webapp/RemoteVolumeProvider.kt b/app/src/main/java/org/jellyfin/mobile/webapp/RemoteVolumeProvider.kt new file mode 100644 index 0000000..133625d --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/webapp/RemoteVolumeProvider.kt @@ -0,0 +1,29 @@ +package org.jellyfin.mobile.webapp + +import android.media.AudioManager +import android.media.VolumeProvider +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.utils.Constants.PLAYBACK_MANAGER_COMMAND_VOL_DOWN +import org.jellyfin.mobile.utils.Constants.PLAYBACK_MANAGER_COMMAND_VOL_UP + +class RemoteVolumeProvider( + private val webappFunctionChannel: WebappFunctionChannel, +) : VolumeProvider(VOLUME_CONTROL_ABSOLUTE, Constants.PERCENT_MAX, 0) { + override fun onAdjustVolume(direction: Int) { + when (direction) { + AudioManager.ADJUST_RAISE -> { + webappFunctionChannel.callPlaybackManagerAction(PLAYBACK_MANAGER_COMMAND_VOL_UP) + currentVolume += 2 // TODO: have web notify app with new volume instead + } + AudioManager.ADJUST_LOWER -> { + webappFunctionChannel.callPlaybackManagerAction(PLAYBACK_MANAGER_COMMAND_VOL_DOWN) + currentVolume -= 2 // TODO: have web notify app with new volume instead + } + } + } + + override fun onSetVolumeTo(volume: Int) { + webappFunctionChannel.setVolume(volume) + currentVolume = volume // TODO: have web notify app with new volume instead + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/webapp/WebViewFragment.kt b/app/src/main/java/org/jellyfin/mobile/webapp/WebViewFragment.kt new file mode 100644 index 0000000..1a360f6 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/webapp/WebViewFragment.kt @@ -0,0 +1,267 @@ +package org.jellyfin.mobile.webapp + +import android.content.Intent +import android.graphics.Rect +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.webkit.ValueCallback +import android.webkit.WebChromeClient.FileChooserParams +import android.webkit.WebView +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.core.view.doOnNextLayout +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.webkit.WebViewAssetLoader.AssetsPathHandler +import androidx.webkit.WebViewCompat +import kotlinx.coroutines.launch +import org.jellyfin.mobile.R +import org.jellyfin.mobile.app.ApiClientController +import org.jellyfin.mobile.app.AppPreferences +import org.jellyfin.mobile.bridge.ExternalPlayer +import org.jellyfin.mobile.bridge.NativeInterface +import org.jellyfin.mobile.bridge.NativePlayer +import org.jellyfin.mobile.data.entity.ServerEntity +import org.jellyfin.mobile.databinding.FragmentWebviewBinding +import org.jellyfin.mobile.setup.ConnectFragment +import org.jellyfin.mobile.utils.AndroidVersion +import org.jellyfin.mobile.utils.BackPressInterceptor +import org.jellyfin.mobile.utils.Constants +import org.jellyfin.mobile.utils.Constants.FRAGMENT_WEB_VIEW_EXTRA_SERVER +import org.jellyfin.mobile.utils.applyDefault +import org.jellyfin.mobile.utils.applyWindowInsetsAsMargins +import org.jellyfin.mobile.utils.dip +import org.jellyfin.mobile.utils.enableServiceWorkerWorkaround +import org.jellyfin.mobile.utils.extensions.getParcelableCompat +import org.jellyfin.mobile.utils.extensions.replaceFragment +import org.jellyfin.mobile.utils.fadeIn +import org.jellyfin.mobile.utils.isOutdated +import org.jellyfin.mobile.utils.requestNoBatteryOptimizations +import org.jellyfin.mobile.utils.runOnUiThread +import org.koin.android.ext.android.inject + +class WebViewFragment : Fragment(), BackPressInterceptor, JellyfinWebChromeClient.FileChooserListener { + val appPreferences: AppPreferences by inject() + private val apiClientController: ApiClientController by inject() + private val webappFunctionChannel: WebappFunctionChannel by inject() + private lateinit var assetsPathHandler: AssetsPathHandler + private lateinit var jellyfinWebViewClient: JellyfinWebViewClient + private val nativePlayer: NativePlayer by inject() + private lateinit var externalPlayer: ExternalPlayer + + lateinit var server: ServerEntity + private set + private var connected = false + private val timeoutRunnable = Runnable { + handleError() + } + private val showLoadingContainerRunnable = Runnable { + webViewBinding?.loadingContainer?.isVisible = true + } + + // UI + private var webViewBinding: FragmentWebviewBinding? = null + + // External file access + private var fileChooserActivityLauncher: ActivityResultLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + fileChooserCallback?.onReceiveValue(FileChooserParams.parseResult(result.resultCode, result.data)) + } + private var fileChooserCallback: ValueCallback>? = null + + init { + enableServiceWorkerWorkaround() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + server = requireNotNull(requireArguments().getParcelableCompat(FRAGMENT_WEB_VIEW_EXTRA_SERVER)) { + "Server entity has not been supplied!" + } + + assetsPathHandler = AssetsPathHandler(requireContext()) + jellyfinWebViewClient = object : JellyfinWebViewClient( + lifecycleScope, + server, + assetsPathHandler, + apiClientController, + ) { + override fun onConnectedToWebapp() { + val webViewBinding = webViewBinding ?: return + val webView = webViewBinding.webView + webView.removeCallbacks(timeoutRunnable) + webView.removeCallbacks(showLoadingContainerRunnable) + connected = true + runOnUiThread { + webViewBinding.loadingContainer.isVisible = false + webView.fadeIn() + } + requestNoBatteryOptimizations(webViewBinding.root) + } + + override fun onErrorReceived() { + handleError() + } + } + externalPlayer = ExternalPlayer(requireContext(), this, requireActivity().activityResultRegistry) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return FragmentWebviewBinding.inflate(inflater, container, false).also { binding -> + webViewBinding = binding + }.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val webView = webViewBinding!!.webView + + // Apply window insets + webView.applyWindowInsetsAsMargins() + + // Setup exclusion rects for gestures + if (AndroidVersion.isAtLeastQ) { + @Suppress("MagicNumber") + webView.doOnNextLayout { + // Maximum allowed exclusion rect height is 200dp, + // offsetting 100dp from the center in both directions + // uses the maximum available space + val verticalCenter = webView.measuredHeight / 2 + val offset = webView.resources.dip(100) + + // Arbitrary, currently 2x minimum touch target size + val exclusionWidth = webView.resources.dip(96) + + webView.systemGestureExclusionRects = listOf( + Rect( + 0, + verticalCenter - offset, + exclusionWidth, + verticalCenter + offset, + ), + ) + } + } + + // Setup WebView + webView.initialize() + + webViewBinding!!.useDifferentServerButton.setOnClickListener { + webView.removeCallbacks(timeoutRunnable) + webView.stopLoading() + webViewBinding!!.loadingContainer.isVisible = false + onSelectServer(error = false) + } + + // Process JS functions called from other components (e.g. the PlayerActivity) + lifecycleScope.launch { + for (function in webappFunctionChannel) { + webView.loadUrl("javascript:$function") + } + } + } + + override fun onInterceptBackPressed(): Boolean { + return connected && webappFunctionChannel.goBack() + } + + override fun onDestroyView() { + super.onDestroyView() + webViewBinding = null + } + + private fun WebView.initialize() { + if (!appPreferences.ignoreWebViewChecks && isOutdated()) { // Check WebView version + showOutdatedWebViewDialog(this) + return + } + webViewClient = jellyfinWebViewClient + webChromeClient = JellyfinWebChromeClient(this@WebViewFragment) + settings.applyDefault() + addJavascriptInterface(NativeInterface(requireContext()), "NativeInterface") + addJavascriptInterface(nativePlayer, "NativePlayer") + addJavascriptInterface(externalPlayer, "ExternalPlayer") + + loadUrl(server.hostname) + postDelayed(timeoutRunnable, Constants.INITIAL_CONNECTION_TIMEOUT) + postDelayed(showLoadingContainerRunnable, Constants.SHOW_PROGRESS_BAR_DELAY) + } + + private fun showOutdatedWebViewDialog(webView: WebView) { + AlertDialog.Builder(requireContext()).apply { + setTitle(R.string.dialog_web_view_outdated) + setMessage(R.string.dialog_web_view_outdated_message) + setCancelable(false) + + val webViewPackage = WebViewCompat.getCurrentWebViewPackage(context) + if (webViewPackage != null) { + val marketUri = Uri.Builder().apply { + scheme("market") + authority("details") + appendQueryParameter("id", webViewPackage.packageName) + }.build() + val referrerUri = Uri.Builder().apply { + scheme("android-app") + authority(context.packageName) + }.build() + + val marketIntent = Intent(Intent.ACTION_VIEW).apply { + data = marketUri + putExtra(Intent.EXTRA_REFERRER, referrerUri) + } + + // Only show button if the intent can be resolved + if (marketIntent.resolveActivity(context.packageManager) != null) { + setNegativeButton(R.string.dialog_button_check_for_updates) { _, _ -> + startActivity(marketIntent) + requireActivity().finishAfterTransition() + } + } + } + if (AndroidVersion.isAtLeastN) { + setPositiveButton(R.string.dialog_button_open_settings) { _, _ -> + startActivity(Intent(Settings.ACTION_WEBVIEW_SETTINGS)) + Toast.makeText(context, R.string.toast_reopen_after_change, Toast.LENGTH_LONG).show() + requireActivity().finishAfterTransition() + } + } + setNeutralButton(R.string.dialog_button_ignore) { _, _ -> + appPreferences.ignoreWebViewChecks = true + // Re-initialize + webView.initialize() + } + }.show() + } + + private fun onSelectServer(error: Boolean = false) = runOnUiThread { + val activity = activity + if (activity != null && activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + val extras = when { + error -> Bundle().apply { + putBoolean(Constants.FRAGMENT_CONNECT_EXTRA_ERROR, true) + } + else -> null + } + parentFragmentManager.replaceFragment(extras) + } + } + + private fun handleError() { + connected = false + onSelectServer(error = true) + } + + override fun onShowFileChooser(intent: Intent, filePathCallback: ValueCallback>) { + fileChooserCallback = filePathCallback + fileChooserActivityLauncher.launch(intent) + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/webapp/WebappFunctionChannel.kt b/app/src/main/java/org/jellyfin/mobile/webapp/WebappFunctionChannel.kt new file mode 100644 index 0000000..5973920 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/webapp/WebappFunctionChannel.kt @@ -0,0 +1,31 @@ +package org.jellyfin.mobile.webapp + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ChannelIterator + +/** + * Allows to call functions within the webapp + */ +class WebappFunctionChannel { + private val internalChannel = Channel() + + operator fun iterator(): ChannelIterator = internalChannel.iterator() + + fun call(action: String) = internalChannel.trySend(action).isSuccess + + // Web component helpers + fun callPlaybackManagerAction(action: String) = call("$PLAYBACK_MANAGER.$action();") + fun setVolume(volume: Int) = call( + "$PLAYBACK_MANAGER.sendCommand({" + + "Name: 'SetVolume', Arguments: { Volume: $volume }" + + "});", + ) + + fun seekTo(pos: Long) = call("$PLAYBACK_MANAGER.seekMs($pos);") + fun goBack() = call("$NAVIGATION_HELPER.goBack();") + + companion object { + private const val NAVIGATION_HELPER = "window.NavigationHelper" + private const val PLAYBACK_MANAGER = "$NAVIGATION_HELPER.playbackManager" + } +} diff --git a/app/src/main/res/color-v24/splash_fill.xml b/app/src/main/res/color-v24/splash_fill.xml new file mode 100644 index 0000000..9c6f0a0 --- /dev/null +++ b/app/src/main/res/color-v24/splash_fill.xml @@ -0,0 +1,8 @@ + diff --git a/app/src/main/res/drawable-hdpi/ic_notification.png b/app/src/main/res/drawable-hdpi/ic_notification.png new file mode 100644 index 0000000..2d9f04f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_notification.png b/app/src/main/res/drawable-ldpi/ic_notification.png new file mode 100644 index 0000000..ba6c329 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_notification.png b/app/src/main/res/drawable-mdpi/ic_notification.png new file mode 100644 index 0000000..ba6c329 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_notification.png b/app/src/main/res/drawable-xhdpi/ic_notification.png new file mode 100644 index 0000000..5f507b5 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_notification.png b/app/src/main/res/drawable-xxhdpi/ic_notification.png new file mode 100644 index 0000000..ba6bada Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_notification.png b/app/src/main/res/drawable-xxxhdpi/ic_notification.png new file mode 100644 index 0000000..99097a3 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable/app_logo.xml b/app/src/main/res/drawable/app_logo.xml new file mode 100644 index 0000000..3aeaa90 --- /dev/null +++ b/app/src/main/res/drawable/app_logo.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_brightness_white_24dp.xml b/app/src/main/res/drawable/ic_brightness_white_24dp.xml new file mode 100644 index 0000000..5e3967d --- /dev/null +++ b/app/src/main/res/drawable/ic_brightness_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_fast_forward_black_32dp.xml b/app/src/main/res/drawable/ic_fast_forward_black_32dp.xml new file mode 100644 index 0000000..0f1de8c --- /dev/null +++ b/app/src/main/res/drawable/ic_fast_forward_black_32dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_fullscreen_enter_white_32dp.xml b/app/src/main/res/drawable/ic_fullscreen_enter_white_32dp.xml new file mode 100644 index 0000000..8e1715a --- /dev/null +++ b/app/src/main/res/drawable/ic_fullscreen_enter_white_32dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_fullscreen_exit_white_32dp.xml b/app/src/main/res/drawable/ic_fullscreen_exit_white_32dp.xml new file mode 100644 index 0000000..08f6279 --- /dev/null +++ b/app/src/main/res/drawable/ic_fullscreen_exit_white_32dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_info_white_24dp.xml b/app/src/main/res/drawable/ic_info_white_24dp.xml new file mode 100644 index 0000000..eacebcd --- /dev/null +++ b/app/src/main/res/drawable/ic_info_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..456a877 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_music_note_white_24dp.xml b/app/src/main/res/drawable/ic_music_note_white_24dp.xml new file mode 100644 index 0000000..0093b80 --- /dev/null +++ b/app/src/main/res/drawable/ic_music_note_white_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_pause_black_42dp.xml b/app/src/main/res/drawable/ic_pause_black_42dp.xml new file mode 100644 index 0000000..8bc74e8 --- /dev/null +++ b/app/src/main/res/drawable/ic_pause_black_42dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_play_black_42dp.xml b/app/src/main/res/drawable/ic_play_black_42dp.xml new file mode 100644 index 0000000..02887c9 --- /dev/null +++ b/app/src/main/res/drawable/ic_play_black_42dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_rewind_black_32dp.xml b/app/src/main/res/drawable/ic_rewind_black_32dp.xml new file mode 100644 index 0000000..885e7f9 --- /dev/null +++ b/app/src/main/res/drawable/ic_rewind_black_32dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_screen_lock_white_24dp.xml b/app/src/main/res/drawable/ic_screen_lock_white_24dp.xml new file mode 100644 index 0000000..d82346c --- /dev/null +++ b/app/src/main/res/drawable/ic_screen_lock_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_screen_unlock_white_24dp.xml b/app/src/main/res/drawable/ic_screen_unlock_white_24dp.xml new file mode 100644 index 0000000..a786f2c --- /dev/null +++ b/app/src/main/res/drawable/ic_screen_unlock_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings_white_24dp.xml b/app/src/main/res/drawable/ic_settings_white_24dp.xml new file mode 100644 index 0000000..384c943 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_white_24dp.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/ic_skip_next_black_32dp.xml b/app/src/main/res/drawable/ic_skip_next_black_32dp.xml new file mode 100644 index 0000000..336a773 --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_next_black_32dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_skip_previous_black_32dp.xml b/app/src/main/res/drawable/ic_skip_previous_black_32dp.xml new file mode 100644 index 0000000..74c45df --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_previous_black_32dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_slow_motion_video_white_24dp.xml b/app/src/main/res/drawable/ic_slow_motion_video_white_24dp.xml new file mode 100644 index 0000000..065afd1 --- /dev/null +++ b/app/src/main/res/drawable/ic_slow_motion_video_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_splash.xml b/app/src/main/res/drawable/ic_splash.xml new file mode 100644 index 0000000..2c0d0c7 --- /dev/null +++ b/app/src/main/res/drawable/ic_splash.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_stop_black_32dp.xml b/app/src/main/res/drawable/ic_stop_black_32dp.xml new file mode 100644 index 0000000..5b37d5c --- /dev/null +++ b/app/src/main/res/drawable/ic_stop_black_32dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_subtitles_off_white_24dp.xml b/app/src/main/res/drawable/ic_subtitles_off_white_24dp.xml new file mode 100644 index 0000000..87c35b9 --- /dev/null +++ b/app/src/main/res/drawable/ic_subtitles_off_white_24dp.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_subtitles_stateful_24dp.xml b/app/src/main/res/drawable/ic_subtitles_stateful_24dp.xml new file mode 100644 index 0000000..ac6dc4c --- /dev/null +++ b/app/src/main/res/drawable/ic_subtitles_stateful_24dp.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_subtitles_white_24dp.xml b/app/src/main/res/drawable/ic_subtitles_white_24dp.xml new file mode 100644 index 0000000..3b4822c --- /dev/null +++ b/app/src/main/res/drawable/ic_subtitles_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_video_settings_white_24dp.xml b/app/src/main/res/drawable/ic_video_settings_white_24dp.xml new file mode 100644 index 0000000..a52d299 --- /dev/null +++ b/app/src/main/res/drawable/ic_video_settings_white_24dp.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_volume_white_24dp.xml b/app/src/main/res/drawable/ic_volume_white_24dp.xml new file mode 100644 index 0000000..61ae639 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/playback_info_background.xml b/app/src/main/res/drawable/playback_info_background.xml new file mode 100644 index 0000000..8daef61 --- /dev/null +++ b/app/src/main/res/drawable/playback_info_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/playback_unlock_background.xml b/app/src/main/res/drawable/playback_unlock_background.xml new file mode 100644 index 0000000..9bdb9a8 --- /dev/null +++ b/app/src/main/res/drawable/playback_unlock_background.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ripple_background.xml b/app/src/main/res/drawable/ripple_background.xml new file mode 100644 index 0000000..86cd55e --- /dev/null +++ b/app/src/main/res/drawable/ripple_background.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ripple_background_circular.xml b/app/src/main/res/drawable/ripple_background_circular.xml new file mode 100644 index 0000000..3eb6c84 --- /dev/null +++ b/app/src/main/res/drawable/ripple_background_circular.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..19058b6 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,8 @@ + + diff --git a/app/src/main/res/layout/exo_player_control_view.xml b/app/src/main/res/layout/exo_player_control_view.xml new file mode 100644 index 0000000..24206ac --- /dev/null +++ b/app/src/main/res/layout/exo_player_control_view.xml @@ -0,0 +1,210 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_compose.xml b/app/src/main/res/layout/fragment_compose.xml new file mode 100644 index 0000000..f33826a --- /dev/null +++ b/app/src/main/res/layout/fragment_compose.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/main/res/layout/fragment_player.xml b/app/src/main/res/layout/fragment_player.xml new file mode 100644 index 0000000..b853dfe --- /dev/null +++ b/app/src/main/res/layout/fragment_player.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml new file mode 100644 index 0000000..c25cfbb --- /dev/null +++ b/app/src/main/res/layout/fragment_settings.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/app/src/main/res/layout/fragment_webview.xml b/app/src/main/res/layout/fragment_webview.xml new file mode 100644 index 0000000..66bf231 --- /dev/null +++ b/app/src/main/res/layout/fragment_webview.xml @@ -0,0 +1,45 @@ + + + + + + + +