Source Code added
Some checks are pending
Repo / Label merge conflict / Triage (push) Waiting to run
Some checks are pending
Repo / Label merge conflict / Triage (push) Waiting to run
This commit is contained in:
parent
ac679f452a
commit
3f20680501
477 changed files with 25051 additions and 2 deletions
28
.editorconfig
Normal file
28
.editorconfig
Normal file
|
|
@ -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
|
||||
83
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
Normal file
83
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
Normal file
|
|
@ -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)
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
|
@ -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
|
||||
13
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
13
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
name: Feature Request
|
||||
about: Request a new feature
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Describe the feature you'd like**
|
||||
<!-- A clear and concise description of what you want to happen. -->
|
||||
|
||||
**Additional context**
|
||||
<!-- Add any other context or screenshots about the feature request here. -->
|
||||
11
.github/pull_request_template.md
vendored
Normal file
11
.github/pull_request_template.md
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<!--
|
||||
Ensure your title is short, descriptive, and in the imperative mood (Fix X, Change Y, instead of Fixed X, Changed Y).
|
||||
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our https://docs.jellyfin.org/general/contributing/issues.html page.
|
||||
-->
|
||||
|
||||
**Changes**
|
||||
<!-- Describe your changes here in 1-5 sentences. -->
|
||||
|
||||
**Issues**
|
||||
<!-- Tag any issues that this PR solves here.
|
||||
ex. Fixes # -->
|
||||
36
.github/workflows/app-build.yaml
vendored
Normal file
36
.github/workflows/app-build.yaml
vendored
Normal file
|
|
@ -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/
|
||||
38
.github/workflows/app-lint.yaml
vendored
Normal file
38
.github/workflows/app-lint.yaml
vendored
Normal file
|
|
@ -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: .
|
||||
102
.github/workflows/app-publish.yaml
vendored
Normal file
102
.github/workflows/app-publish.yaml
vendored
Normal file
|
|
@ -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 .;
|
||||
28
.github/workflows/app-test.yaml
vendored
Normal file
28
.github/workflows/app-test.yaml
vendored
Normal file
|
|
@ -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
|
||||
22
.github/workflows/gradlew-validate.yaml
vendored
Normal file
22
.github/workflows/gradlew-validate.yaml
vendored
Normal file
|
|
@ -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
|
||||
18
.github/workflows/repo-merge-conflict.yaml
vendored
Normal file
18
.github/workflows/repo-merge-conflict.yaml
vendored
Normal file
|
|
@ -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 }}
|
||||
61
.github/workflows/repo-milestone.yaml
vendored
Normal file
61
.github/workflows/repo-milestone.yaml
vendored
Normal file
|
|
@ -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}`);
|
||||
32
.github/workflows/repo-stale.yaml
vendored
Normal file
32
.github/workflows/repo-stale.yaml
vendored
Normal file
|
|
@ -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).
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea
|
||||
.DS_Store
|
||||
build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
339
LICENSE.md
Normal file
339
LICENSE.md
Normal file
|
|
@ -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.
|
||||
98
README.md
98
README.md
|
|
@ -1,3 +1,97 @@
|
|||
# jellyfin
|
||||
<h1 align="center">Jellyfin Android</h1>
|
||||
<h3 align="center">Part of the <a href="https://jellyfin.org">Jellyfin Project</a></h3>
|
||||
|
||||
Streaming Client for Android
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img alt="Logo Banner" src="https://raw.githubusercontent.com/jellyfin/jellyfin-ux/master/branding/SVG/banner-logo-solid.svg?sanitize=true"/>
|
||||
<br/>
|
||||
<br/>
|
||||
<a href="https://github.com/jellyfin/jellyfin-android">
|
||||
<img alt="GPL 2.0 License" src="https://img.shields.io/github/license/jellyfin/jellyfin-android.svg"/>
|
||||
</a>
|
||||
<a href="https://github.com/jellyfin/jellyfin-android/releases">
|
||||
<img alt="Current Release" src="https://img.shields.io/github/release/jellyfin/jellyfin-android.svg"/>
|
||||
</a>
|
||||
<a href="https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-android/">
|
||||
<img alt="Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin-android/-/jellyfin-android/svg-badge.svg"/>
|
||||
</a>
|
||||
<br/>
|
||||
<a href="https://opencollective.com/jellyfin">
|
||||
<img alt="Donate" src="https://img.shields.io/opencollective/all/jellyfin.svg?label=backers"/>
|
||||
</a>
|
||||
<a href="https://features.jellyfin.org">
|
||||
<img alt="Feature Requests" src="https://img.shields.io/badge/fider-vote%20on%20features-success.svg"/>
|
||||
</a>
|
||||
<a href="https://matrix.to/#/+jellyfin:matrix.org">
|
||||
<img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfin:matrix.org.svg?logo=matrix"/>
|
||||
</a>
|
||||
<a href="https://www.reddit.com/r/jellyfin/">
|
||||
<img alt="Join our Subreddit" src="https://img.shields.io/badge/reddit-r%2Fjellyfin-%23FF5700.svg"/>
|
||||
</a>
|
||||
<br/>
|
||||
<a href="https://play.google.com/store/apps/details?id=org.jellyfin.mobile">
|
||||
<img width="153" src="https://jellyfin.org/images/store-icons/google-play.png" alt="Jellyfin on Google Play"/>
|
||||
</a>
|
||||
<a href="https://www.amazon.com/gp/aw/d/B081RFTTQ9">
|
||||
<img width="153" src="https://jellyfin.org/images/store-icons/amazon.png" alt="Jellyfin on Amazon Appstore"/>
|
||||
</a>
|
||||
<a href="https://f-droid.org/en/packages/org.jellyfin.mobile/">
|
||||
<img width="153" src="https://jellyfin.org/images/store-icons/fdroid.png" alt="Jellyfin on F-Droid"/>
|
||||
</a>
|
||||
<br/>
|
||||
<a href="https://repo.jellyfin.org/releases/client/android/">Download archive</a>
|
||||
</p>
|
||||
|
||||
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!
|
||||
|
||||
<a href="https://translate.jellyfin.org/engage/jellyfin-android/">
|
||||
<img alt="Detailed Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin-android/-/jellyfin-android/multi-auto.svg"/>
|
||||
</a>
|
||||
|
||||
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).
|
||||
|
|
|
|||
14
android-lint.xml
Normal file
14
android-lint.xml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<lint>
|
||||
<issue id="ExtraTranslation" severity="warning" />
|
||||
<issue id="MissingQuantity" severity="warning" />
|
||||
<issue id="MissingTranslation" severity="ignore" />
|
||||
<issue id="Typos">
|
||||
<!-- Translations are handled through Weblate -->
|
||||
<ignore regexp="app/src/main/res/values-.*" />
|
||||
</issue>
|
||||
<issue id="TypographyEllipsis">
|
||||
<!-- Weblate doesn't use ellipsis characters -->
|
||||
<ignore regexp="app/src/main/res/values-.*" />
|
||||
</issue>
|
||||
</lint>
|
||||
213
app/build.gradle.kts
Normal file
213
app/build.gradle.kts
Normal file
|
|
@ -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<KotlinCompile> {
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
}
|
||||
}
|
||||
|
||||
withType<Detekt> {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
|
||||
reports {
|
||||
html.required.set(true)
|
||||
xml.required.set(false)
|
||||
txt.required.set(true)
|
||||
sarif.required.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Testing
|
||||
withType<Test> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
34
app/proguard-rules.pro
vendored
Normal file
34
app/proguard-rules.pro
vendored
Normal file
|
|
@ -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;
|
||||
}
|
||||
94
app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/1.json
Normal file
94
app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/1.json
Normal file
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
123
app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/2.json
Normal file
123
app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/2.json
Normal file
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
8
app/src/debug/res/color-v24/splash_fill.xml
Normal file
8
app/src/debug/res/color-v24/splash_fill.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<gradient xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:endColor="#fdc92f"
|
||||
android:endX="752"
|
||||
android:endY="692"
|
||||
android:startColor="#f2364d"
|
||||
android:startX="366"
|
||||
android:startY="469"
|
||||
android:type="linear" />
|
||||
46
app/src/debug/res/drawable/app_logo.xml
Normal file
46
app/src/debug/res/drawable/app_logo.xml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="252dp"
|
||||
android:height="72dp"
|
||||
android:viewportWidth="252"
|
||||
android:viewportHeight="72">
|
||||
<path android:pathData="M24.71,49.16c-1.55,-3.12 8.63,-21.57 11.79,-21.57 3.17,0 13.32,18.49 11.79,21.57 -1.53,3.08 -22.02,3.12 -23.58,0z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="72.5"
|
||||
android:endY="63"
|
||||
android:startX="12.5"
|
||||
android:startY="30"
|
||||
android:tileMode="clamp">
|
||||
<item
|
||||
android:color="#F2364D"
|
||||
android:offset="0" />
|
||||
<item
|
||||
android:color="#FDC92F"
|
||||
android:offset="1" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M0.98,65C-3.69,55.61 26.98,0 36.5,0c9.53,0 40.15,55.71 35.53,65s-66.37,9.39 -71.04,0m12.26,-8.15c3.07,6.15 43.52,6.08 46.55,0 3.03,-6.09 -17.03,-42.59 -23.27,-42.59S10.17,50.69 13.23,56.85z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="72.5"
|
||||
android:endY="63"
|
||||
android:startX="12.5"
|
||||
android:startY="30"
|
||||
android:tileMode="clamp">
|
||||
<item
|
||||
android:color="#F2364D"
|
||||
android:offset="0" />
|
||||
<item
|
||||
android:color="#FDC92F"
|
||||
android:offset="1" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFF"
|
||||
android:pathData="M143.24,14.25c-0.28,0 -0.42,0 -0.52,0.05a0.5,0.5 0,0 0,-0.22 0.22c-0.05,0.11 -0.05,0.25 -0.05,0.53L142.44,55.2c0,0.28 0,0.42 0.05,0.53 0.05,0.09 0.13,0.17 0.22,0.22 0.11,0.05 0.25,0.05 0.53,0.05h5.63c0.28,0 0.42,-0 0.53,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.05,-0.25 0.05,-0.53L149.67,15.06c0,-0.28 0,-0.42 -0.05,-0.53a0.5,0.5 0,0 0,-0.22 -0.22c-0.11,-0.05 -0.25,-0.05 -0.53,-0.05zM154.86,14.25c-0.28,0 -0.42,0 -0.53,0.05a0.5,0.5 0,0 0,-0.22 0.22c-0.05,0.11 -0.05,0.25 -0.05,0.53L154.06,55.2c0,0.28 0,0.42 0.05,0.53 0.05,0.09 0.12,0.17 0.22,0.22 0.11,0.05 0.25,0.05 0.53,0.05h5.62c0.28,0 0.42,-0 0.53,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.05,-0.25 0.05,-0.53L161.28,15.06c0,-0.28 0,-0.42 -0.05,-0.53a0.5,0.5 0,0 0,-0.22 -0.22c-0.11,-0.05 -0.25,-0.05 -0.53,-0.05zM208.55,14.25q-5.18,0 -8.27,2.81 -3.09,2.81 -3.09,7.94L197.19,26h-10.17c-0.3,0 -0.45,0 -0.58,0.05a0.75,0.75 0,0 0,-0.29 0.21c-0.09,0.1 -0.14,0.24 -0.25,0.52l-7.43,19.9 -7.48,-19.9c-0.1,-0.28 -0.16,-0.42 -0.25,-0.52a0.76,0.76 0,0 0,-0.3 -0.2c-0.13,-0.05 -0.28,-0.05 -0.58,-0.05h-5.77c-0.39,0 -0.59,0 -0.72,0.08a0.5,0.5 0,0 0,-0.21 0.31c-0.03,0.15 0.04,0.33 0.19,0.7L174.78,56l-0.66,1.6q-0.88,1.87 -2.04,3.03 -1.1,1.16 -3.58,1.16 -0.88,0 -1.88,-0.17a13,13 0,0 1,-0.73 -0.1c-0.39,-0.06 -0.58,-0.1 -0.71,-0.05a0.47,0.47 0,0 0,-0.26 0.22c-0.07,0.12 -0.07,0.3 -0.07,0.66v4.33c0,0.24 0,0.37 0.05,0.48a0.74,0.74 0,0 0,0.19 0.27c0.09,0.08 0.19,0.12 0.39,0.18a8,8 0,0 0,1.47 0.35q1.16,0.22 2.37,0.22 4.24,0 7.06,-2.43 2.87,-2.37 4.58,-6.73l10.52,-26.58h5.72v22.75c0,0.28 0,0.42 0.05,0.53a0.5,0.5 0,0 0,0.22 0.22c0.11,0.05 0.25,0.05 0.52,0.05h5.63c0.28,0 0.42,-0 0.53,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.05,-0.25 0.05,-0.53L204.42,32.45h5.87c0.28,0 0.42,0 0.52,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.05,-0.25 0.05,-0.53L211.09,26.8c0,-0.28 0,-0.42 -0.05,-0.53a0.5,0.5 0,0 0,-0.22 -0.22c-0.11,-0.05 -0.25,-0.05 -0.52,-0.05h-5.87v-0.99q0,-2.26 1.32,-3.31 1.38,-1.1 3.75,-1.1 0.46,0 0.94,0.05c0.34,0.03 0.52,0.05 0.63,-0a0.48,0.48 0,0 0,0.24 -0.22c0.06,-0.11 0.06,-0.28 0.06,-0.6v-4.43c0,-0.3 0,-0.46 -0.06,-0.59a0.7,0.7 0,0 0,-0.25 -0.29c-0.12,-0.08 -0.26,-0.1 -0.54,-0.13a14,14 0,0 0,-1.97 -0.13zM99.46,14.92c-0.28,0 -0.42,0 -0.53,0.05a0.5,0.5 0,0 0,-0.22 0.22c-0.05,0.11 -0.05,0.25 -0.05,0.52v27.82q0,2.54 -1.6,4.08 -1.54,1.49 -4.19,1.49h-1.57c-0.28,0 -0.42,0 -0.53,0.05a0.5,0.5 0,0 0,-0.22 0.22c-0.05,0.11 -0.05,0.25 -0.05,0.53v5.29c0,0.28 0,0.42 0.05,0.53a0.5,0.5 0,0 0,0.22 0.22c0.11,0.05 0.25,0.05 0.53,0.05h1.57q4.08,0 7.06,-1.6 3.03,-1.6 4.63,-4.47 1.65,-2.87 1.66,-6.67L106.22,15.72c0,-0.28 -0,-0.42 -0.06,-0.53a0.5,0.5 0,0 0,-0.22 -0.22c-0.11,-0.05 -0.25,-0.05 -0.53,-0.05zM213.99,14.92c-0.16,0 -0.26,0.01 -0.34,0.05a0.5,0.5 0,0 0,-0.22 0.22c-0.05,0.11 -0.05,0.25 -0.05,0.52v6.12c0,0.28 0,0.42 0.05,0.53a0.5,0.5 0,0 0,0.22 0.22c0.11,0.05 0.25,0.05 0.53,0.05h5.63c0.28,0 0.42,0 0.52,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.05,-0.25 0.05,-0.53v-6.12c0,-0.28 0,-0.42 -0.05,-0.52a0.5,0.5 0,0 0,-0.22 -0.22c-0.11,-0.05 -0.25,-0.05 -0.52,-0.05zM124.56,25.34q-4.19,0 -7.61,2.04 -3.36,2.04 -5.35,5.57 -1.93,3.47 -1.93,8 0,4.36 1.93,7.94c1.93,3.59 3.09,4.28 5.4,5.68q3.47,2.1 8.11,2.1 4.58,0 8,-2.04 3.09,-1.87 4.53,-4.61c0.11,-0.22 0.17,-0.33 0.17,-0.45a0.53,0.53 0,0 0,-0.1 -0.3c-0.07,-0.1 -0.19,-0.16 -0.43,-0.27l-4.27,-2.09c-0.31,-0.15 -0.47,-0.23 -0.61,-0.24a0.6,0.6 0,0 0,-0.35 0.08c-0.12,0.07 -0.24,0.22 -0.48,0.54a8.2,8.2 0,0 1,-2.21 1.99q-1.71,1.05 -4.19,1.05 -3.26,0 -5.46,-1.98 -2.2,-1.99 -2.54,-5.3h21.06c0.2,0 0.3,0 0.39,-0.04a0.55,0.55 0,0 0,0.21 -0.17c0.06,-0.08 0.08,-0.17 0.12,-0.33q0.09,-0.39 0.12,-0.84 0.11,-0.83 0.11,-1.65 0,-4.03 -1.71,-7.33t-4.96,-5.3q-3.26,-2.04 -7.94,-2.04m115.64,0q-2.81,0 -5.07,1.1a7.9,7.9 0,0 0,-3.42 3.25L231.71,26.8c0,-0.28 0,-0.42 -0.05,-0.53a0.5,0.5 0,0 0,-0.22 -0.22c-0.11,-0.05 -0.25,-0.05 -0.53,-0.05h-5.18c-0.28,0 -0.42,0 -0.53,0.05a0.5,0.5 0,0 0,-0.22 0.22c-0.05,0.11 -0.05,0.25 -0.05,0.53v28.4c0,0.28 0,0.42 0.05,0.53a0.5,0.5 0,0 0,0.22 0.22c0.11,0.05 0.25,0.05 0.53,0.05h5.62c0.28,0 0.42,0 0.53,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.05,-0.25 0.05,-0.53v-16.79q0,-2.92 1.65,-4.69 1.71,-1.77 4.41,-1.77 2.7,0 4.36,1.77 1.71,1.71 1.71,4.69v16.79c0,0.28 -0,0.42 0.05,0.53a0.5,0.5 0,0 0,0.22 0.22c0.11,0.05 0.25,0.05 0.53,0.05h5.63c0.28,0 0.42,0 0.53,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.06,-0.25 0.06,-0.53v-18.5q-0,-3.37 -1.44,-5.9a10.1,10.1 0,0 0,-4.03 -4.03q-2.54,-1.43 -5.85,-1.43M214.18,26c-0.28,0 -0.42,0 -0.53,0.05a0.5,0.5 0,0 0,-0.22 0.22c-0.05,0.11 -0.05,0.25 -0.05,0.53v28.4c0,0.28 0,0.42 0.05,0.53a0.5,0.5 0,0 0,0.22 0.22c0.11,0.05 0.25,0.05 0.53,0.05h5.63c0.28,0 0.42,0 0.52,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.05,-0.25 0.05,-0.53L220.61,26.8c0,-0.28 0,-0.42 -0.05,-0.53a0.5,0.5 0,0 0,-0.22 -0.22c-0.11,-0.05 -0.25,-0.05 -0.53,-0.05zM124.56,31.3q2.87,0 4.74,1.76 1.93,1.71 2.15,4.47h-14.12q0.61,-2.98 2.54,-4.58 1.99,-1.65 4.69,-1.65" />
|
||||
</vector>
|
||||
19
app/src/debug/res/drawable/ic_launcher_foreground.xml
Normal file
19
app/src/debug/res/drawable/ic_launcher_foreground.xml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="1024"
|
||||
android:viewportHeight="1024">
|
||||
<path android:pathData="m512 284.789466c-57.344 0-241.850182 334.568734-213.736727 391.074914 28.113454 56.50618 399.639277 55.85454 427.473457 0 27.83418-55.85455-156.39273-391.074914-213.73673-391.074914zm140.10182 342.109094c-18.24582 36.58473-261.67855 37.05018-280.11055 0-18.431997-37.05018 102.49309-256.27928 140.00873-256.27928s158.34764 219.60146 140.10182 256.27928zm-140.10182-176.128c-18.99055 0-80.24436 111.05745-70.93527 129.76873 9.30909 18.71127 132.65454 18.52509 141.87054 0s-51.85163-129.76873-70.93527-129.76873z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endColor="#FDC92F"
|
||||
android:endX="752"
|
||||
android:endY="692"
|
||||
android:startColor="#F2364D"
|
||||
android:startX="366"
|
||||
android:startY="469"
|
||||
android:type="linear" />
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
4
app/src/debug/res/values/colors.xml
Normal file
4
app/src/debug/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#000000</color>
|
||||
</resources>
|
||||
4
app/src/debug/res/values/strings_donottranslate.xml
Normal file
4
app/src/debug/res/values/strings_donottranslate.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">Jellyfin Debug</string>
|
||||
</resources>
|
||||
9
app/src/libre/AndroidManifest.xml
Normal file
9
app/src/libre/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application>
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.version"
|
||||
tools:node="remove" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
108
app/src/main/AndroidManifest.xml
Normal file
108
app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="auto">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH"
|
||||
android:maxSdkVersion="30"
|
||||
android:required="false" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_CONNECT"
|
||||
android:required="false"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:targetApi="s" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage"
|
||||
tools:node="remove" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent>
|
||||
|
||||
<package android:name="com.mxtech.videoplayer.ad" />
|
||||
<package android:name="com.mxtech.videoplayer.pro" />
|
||||
<package android:name="is.xyz.mpv" />
|
||||
<package android:name="org.videolan.vlc" />
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".JellyfinApplication"
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="@xml/backup_content"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme.Starting"
|
||||
tools:ignore="UnusedAttribute">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboard|keyboardHidden|navigation|uiMode"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:supportsPictureInPicture="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- declare legacy support for voice actions -->
|
||||
<intent-filter>
|
||||
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
||||
<service
|
||||
android:name=".webapp.RemotePlayerService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name="org.jellyfin.mobile.player.audio.MediaService"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name="androidx.mediarouter.media.MediaTransferReceiver"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedReceiver" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.version"
|
||||
android:value="@integer/google_play_services_version" />
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
||||
android:value="org.jellyfin.mobile.player.cast.CastOptionsProvider" />
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.notification.SmallIcon"
|
||||
android:resource="@mipmap/ic_launcher_round" />
|
||||
<meta-data
|
||||
android:name="android.webkit.WebView.EnableSafeBrowsing"
|
||||
android:value="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
442
app/src/main/assets/native/EventEmitter.js
Normal file
442
app/src/main/assets/native/EventEmitter.js
Normal file
|
|
@ -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;
|
||||
}());
|
||||
179
app/src/main/assets/native/ExoPlayerPlugin.js
Normal file
179
app/src/main/assets/native/ExoPlayerPlugin.js
Normal file
|
|
@ -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: []
|
||||
};
|
||||
}
|
||||
}
|
||||
152
app/src/main/assets/native/ExternalPlayerPlugin.js
Normal file
152
app/src/main/assets/native/ExternalPlayerPlugin.js
Normal file
|
|
@ -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: []
|
||||
};
|
||||
}
|
||||
}
|
||||
16
app/src/main/assets/native/NavigationPlugin.js
Normal file
16
app/src/main/assets/native/NavigationPlugin.js
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
1443
app/src/main/assets/native/chrome.cast.js
Normal file
1443
app/src/main/assets/native/chrome.cast.js
Normal file
File diff suppressed because it is too large
Load diff
15
app/src/main/assets/native/injectionScript.js
Normal file
15
app/src/main/assets/native/injectionScript.js
Normal file
|
|
@ -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();
|
||||
})();
|
||||
189
app/src/main/assets/native/nativeshell.js
Normal file
189
app/src/main/assets/native/nativeshell.js
Normal file
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
41
app/src/main/java/org/jellyfin/mobile/JellyfinApplication.kt
Normal file
41
app/src/main/java/org/jellyfin/mobile/JellyfinApplication.kt
Normal file
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
207
app/src/main/java/org/jellyfin/mobile/MainActivity.kt
Normal file
207
app/src/main/java/org/jellyfin/mobile/MainActivity.kt
Normal file
|
|
@ -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<ConnectFragment>()
|
||||
}
|
||||
}
|
||||
is ServerState.Available -> {
|
||||
if (currentFragment !is WebViewFragment || currentFragment.server != state.server) {
|
||||
replaceFragment<WebViewFragment>(
|
||||
Bundle().apply {
|
||||
putParcelable(Constants.FRAGMENT_WEB_VIEW_EXTRA_SERVER, state.server)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
49
app/src/main/java/org/jellyfin/mobile/MainViewModel.kt
Normal file
49
app/src/main/java/org/jellyfin/mobile/MainViewModel.kt
Normal file
|
|
@ -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<ServerState> = MutableStateFlow(ServerState.Pending)
|
||||
val serverState: StateFlow<ServerState> 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()
|
||||
}
|
||||
|
|
@ -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<ServerEntity> = 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
19
app/src/main/java/org/jellyfin/mobile/app/ApiModule.kt
Normal file
19
app/src/main/java/org/jellyfin/mobile/app/ApiModule.kt
Normal file
|
|
@ -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<Jellyfin>().createApi() }
|
||||
}
|
||||
153
app/src/main/java/org/jellyfin/mobile/app/AppModule.kt
Normal file
153
app/src/main/java/org/jellyfin/mobile/app/AppModule.kt
Normal file
|
|
@ -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<PlayerEvent>() }
|
||||
|
||||
// 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<DataSource.Factory> {
|
||||
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<MediaSource.Factory> {
|
||||
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<DataSource.Factory>(), extractorsFactory)
|
||||
}
|
||||
single { ProgressiveMediaSource.Factory(get()) }
|
||||
single { HlsMediaSource.Factory(get<DataSource.Factory>()) }
|
||||
single { SingleSampleMediaSource.Factory(get()) }
|
||||
|
||||
// Media components
|
||||
single { LibraryBrowser(get(), get()) }
|
||||
}
|
||||
127
app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt
Normal file
127
app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt
Normal file
|
|
@ -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) }
|
||||
}
|
||||
317
app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt
Normal file
317
app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt
Normal file
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
171
app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt
Normal file
171
app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
65
app/src/main/java/org/jellyfin/mobile/bridge/NativePlayer.kt
Normal file
65
app/src/main/java/org/jellyfin/mobile/bridge/NativePlayer.kt
Normal file
|
|
@ -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<PlayerEvent>,
|
||||
) {
|
||||
|
||||
@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))
|
||||
}
|
||||
}
|
||||
17
app/src/main/java/org/jellyfin/mobile/data/DatabaseModule.kt
Normal file
17
app/src/main/java/org/jellyfin/mobile/data/DatabaseModule.kt
Normal file
|
|
@ -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<JellyfinDatabase>().serverDao }
|
||||
single { get<JellyfinDatabase>().userDao }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
25
app/src/main/java/org/jellyfin/mobile/data/dao/ServerDao.kt
Normal file
25
app/src/main/java/org/jellyfin/mobile/data/dao/ServerDao.kt
Normal file
|
|
@ -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<ServerEntity>
|
||||
|
||||
@Query("SELECT * FROM $TABLE_NAME WHERE hostname = :hostname")
|
||||
fun getServerByHostname(hostname: String): ServerEntity?
|
||||
}
|
||||
54
app/src/main/java/org/jellyfin/mobile/data/dao/UserDao.kt
Normal file
54
app/src/main/java/org/jellyfin/mobile/data/dao/UserDao.kt
Normal file
|
|
@ -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<UserEntity>
|
||||
|
||||
@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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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<ActivityEvent>(
|
||||
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<PlayerFragment>(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<SettingsFragment>()
|
||||
}
|
||||
ActivityEvent.SelectServer -> {
|
||||
mainViewModel.resetServer()
|
||||
}
|
||||
ActivityEvent.ExitApp -> {
|
||||
if (serviceBinder?.isPlaying == true) {
|
||||
moveTaskToBack(false)
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun emit(event: ActivityEvent) {
|
||||
eventsFlow.tryEmit(event)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
551
app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt
Normal file
551
app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt
Normal file
|
|
@ -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<Application>().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<ExoPlayer?>()
|
||||
private val _playerState = MutableLiveData<Int>()
|
||||
private val _decoderType = MutableLiveData<DecoderType>()
|
||||
val player: LiveData<ExoPlayer?> get() = _player
|
||||
val playerState: LiveData<Int> get() = _playerState
|
||||
val decoderType: LiveData<DecoderType> get() = _decoderType
|
||||
|
||||
private val _error = MutableLiveData<String>()
|
||||
val error: LiveData<String> = _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<PlayerEvent> by inject(named(PLAYER_EVENT_CHANNEL))
|
||||
|
||||
val mediaSession: MediaSession by lazy {
|
||||
MediaSession(
|
||||
getApplication<Application>().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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MediaMetadataCompat> = 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<MediaSource.Factory>()).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<List<MediaItem>>) {
|
||||
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<MediaMetadataCompat>,
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MediaBrowserCompat.MediaItem>? {
|
||||
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<List<MediaMetadataCompat>, 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<MediaMetadataCompat>? {
|
||||
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<MediaMetadataCompat>? = getLibraries().firstOrNull()?.mediaId?.let { defaultLibrary ->
|
||||
val libraryId = defaultLibrary.split('|').getOrNull(1) ?: return@let null
|
||||
|
||||
getRecents(libraryId.toUUID())
|
||||
}
|
||||
|
||||
private suspend fun getLibraries(): List<MediaBrowserCompat.MediaItem> {
|
||||
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<MediaBrowserCompat.MediaItem> {
|
||||
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<MediaMetadataCompat>? {
|
||||
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<MediaBrowserCompat.MediaItem>? {
|
||||
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<MediaBrowserCompat.MediaItem>? {
|
||||
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<MediaBrowserCompat.MediaItem>? {
|
||||
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<MediaBrowserCompat.MediaItem>? {
|
||||
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<MediaMetadataCompat>? {
|
||||
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<MediaMetadataCompat>? {
|
||||
val result by playlistsApi.getPlaylistItems(
|
||||
playlistId = playlistId,
|
||||
)
|
||||
|
||||
return result.extractItems("${LibraryPage.PLAYLIST}|$playlistId")
|
||||
}
|
||||
|
||||
private fun BaseItemDtoQueryResult.extractItems(libraryId: String? = null): List<MediaMetadataCompat>? =
|
||||
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<MediaMetadataCompat>.browsable(): List<MediaBrowserCompat.MediaItem> = map { metadata ->
|
||||
MediaBrowserCompat.MediaItem(metadata.description, FLAG_BROWSABLE)
|
||||
}
|
||||
|
||||
private fun List<MediaMetadataCompat>.playable(): List<MediaBrowserCompat.MediaItem> = map { metadata ->
|
||||
MediaBrowserCompat.MediaItem(metadata.description, FLAG_PLAYABLE)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package org.jellyfin.mobile.player.cast
|
||||
|
||||
import com.google.android.exoplayer2.Player
|
||||
|
||||
interface ICastPlayerProvider {
|
||||
val isCastSessionAvailable: Boolean
|
||||
|
||||
fun get(): Player?
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
val maxBitrate: Int,
|
||||
) {
|
||||
class Video(
|
||||
name: String,
|
||||
mimeType: String,
|
||||
profiles: Set<String>,
|
||||
private val levels: Set<Int>,
|
||||
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<String>,
|
||||
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<String>()
|
||||
val levels = HashSet<Int>()
|
||||
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<String>()
|
||||
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<Int>::getUpper),
|
||||
)
|
||||
}
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Array<String>>
|
||||
private val supportedAudioCodecs: Array<Array<String>>
|
||||
|
||||
private val transcodingProfiles: List<TranscodingProfile>
|
||||
|
||||
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<String, DeviceCodec.Video> = HashMap()
|
||||
val audioCodecs: MutableMap<String, DeviceCodec.Audio> = 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<ContainerProfile>()
|
||||
val directPlayProfiles = ArrayList<DirectPlayProfile>()
|
||||
val codecProfiles = ArrayList<CodecProfile>()
|
||||
|
||||
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<String>, external: Array<String>): List<SubtitleProfile> = ArrayList<SubtitleProfile>().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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<UUID>,
|
||||
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<UUID>().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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
}
|
||||
|
|
@ -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<ApiClient>().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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package org.jellyfin.mobile.player.qualityoptions
|
||||
|
||||
data class QualityOption(
|
||||
val maxHeight: Int,
|
||||
val bitrate: Int,
|
||||
)
|
||||
|
|
@ -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<QualityOption> {
|
||||
// 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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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<UUID> = emptyList()
|
||||
private var currentQueueIndex: Int = 0
|
||||
|
||||
private val _currentMediaSource: MutableLiveData<JellyfinMediaSource> = MutableLiveData()
|
||||
val currentMediaSource: LiveData<JellyfinMediaSource>
|
||||
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<ProgressiveMediaSource.Factory>()
|
||||
}
|
||||
MediaProtocol.HTTP -> {
|
||||
val url = requireNotNull(sourceInfo.path)
|
||||
val factory = get<HlsMediaSource.Factory>().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<ProgressiveMediaSource.Factory>()
|
||||
}
|
||||
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<HlsMediaSource.Factory>().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<MediaSource> {
|
||||
val factory = get<SingleSampleMediaSource.Factory>()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MediaStream> = sourceInfo.mediaStreams.orEmpty()
|
||||
val audioStreams: List<MediaStream>
|
||||
val subtitleStreams: List<MediaStream>
|
||||
val externalSubtitleStreams: List<ExternalSubtitleStream>
|
||||
|
||||
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<MediaStream>()
|
||||
val subtitles = ArrayList<MediaStream>()
|
||||
val externalSubtitles = ArrayList<ExternalSubtitleStream>()
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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<JellyfinMediaSource> {
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package org.jellyfin.mobile.player.ui
|
||||
|
||||
/**
|
||||
* Represents the type of decoder
|
||||
*/
|
||||
enum class DecoderType {
|
||||
HARDWARE,
|
||||
SOFTWARE,
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package org.jellyfin.mobile.player.ui
|
||||
|
||||
data class PlayState(
|
||||
val playWhenReady: Boolean,
|
||||
val position: Long,
|
||||
)
|
||||
|
|
@ -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<PlayOptions>(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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
350
app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt
Normal file
350
app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt
Normal file
|
|
@ -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<MediaStream>,
|
||||
@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<MediaStream>,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package org.jellyfin.mobile.player.ui
|
||||
|
||||
fun interface TrackSelectionCallback {
|
||||
fun onTrackSelected(success: Boolean)
|
||||
}
|
||||
|
|
@ -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~";
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<RecommendedServerInfo>()
|
||||
val goodServers = mutableListOf<RecommendedServerInfo>()
|
||||
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<ServerDiscoveryInfo> =
|
||||
jellyfin.discovery
|
||||
.discoverLocalServers(maxServers = LocalServerDiscovery.DISCOVERY_MAX_SERVERS)
|
||||
.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ServerSuggestion>() }
|
||||
var checkUrlState by remember<MutableState<CheckUrlState>> { 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<ServerSuggestion>,
|
||||
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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package org.jellyfin.mobile.ui.state
|
||||
|
||||
enum class ServerSelectionMode {
|
||||
ADDRESS,
|
||||
AUTO_DISCOVERY,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue