Source Code added
Some checks are pending
Repo / Label merge conflict / Triage (push) Waiting to run

This commit is contained in:
Fr4nz D13trich 2026-02-02 14:56:38 +01:00
parent ac679f452a
commit 3f20680501
477 changed files with 25051 additions and 2 deletions

28
.editorconfig Normal file
View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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
View 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
View file

@ -0,0 +1,9 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
build
/captures
.externalNativeBuild
.cxx

339
LICENSE.md Normal file
View 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.

View file

@ -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
View 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
View 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
View 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;
}

View 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')"
]
}
}

View 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')"
]
}
}

View 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" />

View 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>

View 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>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#000000</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">Jellyfin Debug</string>
</resources>

View 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>

View file

@ -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
}

View file

@ -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
}

View 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>

View 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;
}());

View 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: []
};
}
}

View 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: []
};
}
}

View 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();
}
}
}

File diff suppressed because it is too large Load diff

View 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();
})();

View 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();
}
};

View 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,
)
}
}
}

View 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()
}
}

View 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()
}

View file

@ -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,
)
}
}

View 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() }
}

View 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()) }
}

View 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) }
}

View 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,
)
}
}

View file

@ -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)
}

View 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)
}
}

View 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))
}
}

View 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 }
}

View file

@ -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
}

View 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?
}

View 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)
}

View file

@ -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"
}
}

View file

@ -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,
)

View file

@ -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"
}
}

View file

@ -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()
}

View file

@ -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)
}
}

View file

@ -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)
}

View 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()
}
}

View file

@ -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
}
}

View file

@ -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()
}
}
}

View file

@ -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"
}
}

View file

@ -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)
}
}

View file

@ -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"
}

View file

@ -0,0 +1,9 @@
package org.jellyfin.mobile.player.cast
import com.google.android.exoplayer2.Player
interface ICastPlayerProvider {
val isCastSessionAvailable: Boolean
fun get(): Player?
}

View file

@ -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()
}

View file

@ -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
}
}

View file

@ -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
}
}
}
}

View file

@ -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
}
}

View file

@ -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
}
}
}

View file

@ -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()
}

View file

@ -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()
}
}
}

View file

@ -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()
}
}

View file

@ -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,
),
}

View file

@ -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)
}
}

View file

@ -0,0 +1,6 @@
package org.jellyfin.mobile.player.qualityoptions
data class QualityOption(
val maxHeight: Int,
val bitrate: Int,
)

View file

@ -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 }
}
}

View file

@ -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
}
}

View file

@ -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:"
}
}

View file

@ -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")
}
}

View file

@ -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))
}
}
}

View file

@ -0,0 +1,9 @@
package org.jellyfin.mobile.player.ui
/**
* Represents the type of decoder
*/
enum class DecoderType {
HARDWARE,
SOFTWARE,
}

View file

@ -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,
)

View file

@ -0,0 +1,6 @@
package org.jellyfin.mobile.player.ui
data class PlayState(
val playWhenReady: Boolean,
val position: Long,
)

View file

@ -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
}
}
}

View file

@ -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()
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View 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
}
}

View file

@ -0,0 +1,5 @@
package org.jellyfin.mobile.player.ui
fun interface TrackSelectionCallback {
fun onTrackSelected(success: Boolean)
}

View file

@ -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~";
}

View file

@ -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"
}
}

View file

@ -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";
}

View file

@ -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,
)
}
}
}
}

View file

@ -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)
}

View file

@ -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,
)
}
}

View file

@ -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)
},
)
}

View file

@ -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,
}
}

View file

@ -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()
}

View file

@ -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