Compare commits

..

11 Commits

Author SHA1 Message Date
Bill Thornton
a67c486385 Merge pull request #311 from jellyfin/v0.11.1
v0.11.1
2020-02-06 23:40:00 -05:00
Bill Thornton
8aa0c5a18e Disable all subtitle rendering in exoplayer 2020-02-06 23:23:20 -05:00
Florianisme
4c5eab8d62 Fix wrong last position when media player is closed and progress still reported
(cherry picked from commit 8cd11c888c)
2020-02-06 14:58:43 +01:00
Bill Thornton
5e016b38b0 Fix crash on empty server address 2020-02-06 01:09:41 -05:00
Niels van Velzen
5e4e135239 Fix default home sections not used when the user partially changed them 2020-02-06 00:08:45 -05:00
Andreas
8c1d6c30de Fix displaying ACRA as enabled by default in preferences 2020-02-06 00:08:36 -05:00
Niels van Velzen
ec83ec0540 Update version to 0.11.1 2020-02-06 00:06:50 -05:00
Bill Thornton
32dd0ae3ce Merge pull request #289 from nielsvanvelzen/jitpack-finally-passed
Set apiclient version to 0.5.0
2020-02-04 08:22:52 -05:00
Niels van Velzen
e258ae8113 Set apiclient version to 0.5.0 2020-02-04 10:05:01 +01:00
Bill Thornton
c11d26c887 Merge remote-tracking branch 'upstream/master' into release 2020-02-03 17:06:58 -05:00
Bill Thornton
afa1f918ee Set fixed apiclient version 2019-12-11 00:06:38 -05:00
1066 changed files with 34389 additions and 56685 deletions

16
.ci/azure-pipelines.yml Normal file
View File

@@ -0,0 +1,16 @@
trigger:
- master
- release
pool:
vmImage: 'ubuntu-latest'
steps:
- task: Gradle@2
inputs:
gradleWrapperFile: 'gradlew'
tasks: 'build'
publishJUnitResults: true
testResultsFiles: '**/TEST-*.xml'
javaHomeOption: 'JDKVersion'
sonarQubeRunAnalysis: false

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.git*
.dockerignore
Dockerfile
build
new-file-header.txt
CONTRIBUTORS.md
README.md

View File

@@ -1,17 +1,8 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[{*.kts, *.kt}]
charset = utf-8
indent_style = tab
tab_width = 4
# Disable wildcard imports in IntelliJ/Android Studio
ij_kotlin_name_count_to_use_star_import = 1000
ij_kotlin_name_count_to_use_star_import_for_members = 1000
ij_kotlin_packages_to_use_import_on_demand =
[*.md]
trim_trailing_whitespace = false

6
.gitattributes vendored
View File

@@ -1,7 +1 @@
*.kt text eol=lf
*.kts text eol=lf
*.java text eol=lf
*.xml text eol=lf
CONTRIBUTORS.md merge=union
app/src/main/res/values*/strings.xml merge=union

View File

@@ -1,82 +0,0 @@
name: Media playback bug report
description: Create a bug report related to media playback
labels:
- bug
- playback
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 crash logs here if applicable. You can find these in your servers dashboard under "logs".
The file name should start with "upload_org.jellyfin.androidtv".
Make sure that they don't contain any sensitive information like server URL, auth tokens or passwords.
placeholder: Paste logs…
- type: textarea
id: logs-ffmpeg
attributes:
label: FFmpeg logs
description: |
Please paste your FFmpeg logs here. You can find these in your servers dashboard under "logs".
placeholder: Paste logs…
render: shell
- type: textarea
id: mediainfo
attributes:
label: Media info of the file
description: |
Please share the media information for the file causing issues. You can use a variety of tools to retrieve this information.
- Use ffprobe (`ffprobe ./file.mp4`)
- Use the Media Info tool (set to text format, download here: https://mediaarea.net/en/MediaInfo)
- Copy the media info from the web interface
placeholder: Paste media info…
render: shell
- type: input
id: app-version
attributes:
label: Application version
description: The version of the installed Jellyfin Android TV app.
placeholder: 0.14.0
validations:
required: true
- type: input
id: device-info
attributes:
label: Device information
description: Manufacturer and model
placeholder: Nvidia Shield Pro (2017), Amazon Fire TV Stick v1 (2014)
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 9, Fire OS 7
validations:
required: true
- type: input
id: server-version
attributes:
label: Jellyfin server version
description: If on unstable, please specify the commit hash.
placeholder: 10.8.1
validations:
required: true

View File

@@ -1,71 +0,0 @@
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 crash logs here if applicable. You can find these in your servers dashboard under "logs".
The file name should start with "upload_org.jellyfin.androidtv".
Make sure that they don't contain any sensitive information like server URL, auth tokens or passwords.
placeholder: Paste logs…
- type: input
id: app-version
attributes:
label: Application version
description: The version of the installed Jellyfin Android TV app.
placeholder: 0.14.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
- Amazon Appstore
- Sideloaded APK
- type: input
id: device-info
attributes:
label: Device information
description: Manufacturer and model
placeholder: Nvidia Shield Pro (2017), Amazon Fire TV Stick v1 (2014)
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 9, Fire OS 7
validations:
required: true
- type: input
id: server-version
attributes:
label: Jellyfin server version
description: If on unstable, please specify the commit hash.
placeholder: 10.8.1
validations:
required: true

View File

@@ -1,8 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Jellyfin documentation
url: https://jellyfin.org/docs/general/getting-help.html
about: Our documentation contains lots of help for common issues
- name: Jellyfin contact
url: https://jellyfin.org/contact/
about: Our community is ready to help you get started with Jellyfin

View File

@@ -1,14 +0,0 @@
name: Feature request
description: Request a new feature
labels:
- enhancement
body:
- type: textarea
id: description
attributes:
label: Describe the feature you'd like
description: |
A clear and concise description of what you want to request.
You can also attach screenshots or screen recordings to help explain your request.
validations:
required: true

View File

@@ -1,11 +0,0 @@
<!--
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 # -->

View File

@@ -1,36 +0,0 @@
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@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
- name: Setup Java
uses: actions/setup-java@de1bb2b0c5634f0fc4438d7aa9944e68f9bf86cc # tag=v3
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/gradle-build-action@3fbe033aaae657f011f88f29be9e65ed26bd29ef # tag=v2
- 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@83fd05a356d7e2593de66fc9913b3002723633cb # tag=v3
with:
name: build-artifacts
retention-days: 14
if-no-files-found: error
path: build/gh-app-publish/

View File

@@ -1,34 +0,0 @@
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
steps:
- name: Checkout repository
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
- name: Setup Java
uses: actions/setup-java@de1bb2b0c5634f0fc4438d7aa9944e68f9bf86cc # tag=v3
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/gradle-build-action@3fbe033aaae657f011f88f29be9e65ed26bd29ef # tag=v2
- name: Run detekt and lint tasks
run: ./gradlew detekt lint
- name: Upload SARIF files
uses: github/codeql-action/upload-sarif@678fc3afe258fb2e0cdc165ccf77b85719de7b3c # v2
if: ${{ always() }}
with:
sarif_file: .

View File

@@ -1,80 +0,0 @@
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@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
- name: Setup Java
uses: actions/setup-java@de1bb2b0c5634f0fc4438d7aa9944e68f9bf86cc # tag=v3
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/gradle-build-action@3fbe033aaae657f011f88f29be9e65ed26bd29ef # tag=v2
- name: Set JELLYFIN_VERSION
run: echo "JELLYFIN_VERSION=$(echo ${GITHUB_REF#refs/tags/v} | tr / -)" >> $GITHUB_ENV
- name: Assemble release files
run: ./gradlew assemble bundleRelease versionTxt
- name: Sign APK
id: signApk
uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # tag=v1
with:
releaseDirectory: app/build/outputs/apk/release
signingKeyBase64: ${{ secrets.KEYSTORE }}
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
alias: ${{ secrets.KEY_ALIAS }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Sign app bundle
id: signAab
uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # tag=v1
with:
releaseDirectory: app/build/outputs/bundle/release
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-androidtv-*-debug.apk build/jellyfin-publish/
mv app/build/outputs/apk/*/jellyfin-androidtv-*-release-unsigned.apk build/jellyfin-publish/
mv ${{ steps.signApk.outputs.signedReleaseFile }} build/jellyfin-publish/jellyfin-androidtv-v${{ env.JELLYFIN_VERSION }}-release.apk
mv ${{ steps.signAab.outputs.signedReleaseFile }} build/jellyfin-publish/jellyfin-androidtv-v${{ env.JELLYFIN_VERSION }}-release.aab
mv app/build/version.txt build/jellyfin-publish/
- name: Upload release archive to GitHub release
uses: alexellis/upload-assets@259de5111cb56966d046ced998941e93f91d2c93 # tag=0.4.0
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@0dc935cdecc5f5e571865e60d2a6cdc673704823 # tag=5.2
with:
switches: -vrptz
path: build/jellyfin-publish/
remote_path: /srv/repository/releases/client/androidtv/versions/v${{ env.JELLYFIN_VERSION }}
remote_host: ${{ secrets.DEPLOY_HOST }}
remote_user: ${{ secrets.DEPLOY_USER }}
remote_key: ${{ secrets.DEPLOY_KEY }}
- name: Update repo.jellyfin.org symlinks
uses: appleboy/ssh-action@f9010ff7f1bbd7db1a0b4bab661437550cea20c0 # tag=v0.1.5
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
envs: JELLYFIN_VERSION
script_stop: true
script: |
cd /srv/repository/releases/client/androidtv;
rm -rf *.apk version.txt;
ln -s versions/v${JELLYFIN_VERSION}/jellyfin-androidtv-v${JELLYFIN_VERSION}-*.apk .;
ln -s versions/v${JELLYFIN_VERSION}/version.txt .;

View File

@@ -1,28 +0,0 @@
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@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
- name: Setup Java
uses: actions/setup-java@de1bb2b0c5634f0fc4438d7aa9944e68f9bf86cc # tag=v3
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/gradle-build-action@3fbe033aaae657f011f88f29be9e65ed26bd29ef # tag=v2
- name: Run test task
run: ./gradlew test

View File

@@ -1,18 +0,0 @@
name: Gradle / Update wrapper
on:
schedule:
- cron: '0 4 * * *'
jobs:
update:
name: Update
runs-on: ubuntu-22.04
if: ${{ contains(github.repository_owner, 'jellyfin') }}
steps:
- name: Checkout repository
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
- name: Update Gradle Wrapper
uses: gradle-update/update-gradle-wrapper-action@981c551b17dbcf1940b1b4435afdb79babb7c13a # tag=v1
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}

View File

@@ -1,22 +0,0 @@
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@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@55e685c48d84285a5b0418cd094606e199cca3b6 # tag=v1

View File

@@ -1,18 +0,0 @@
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@fd1f295ee7443d13745804bc49fe158e240f6c6e # tag=v2.1.0
with:
dirtyLabel: merge conflict
repoToken: ${{ secrets.JF_BOT_TOKEN }}

View File

@@ -1,32 +0,0 @@
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@5ebf00ea0e4c1561e9b43a292ed34424fb1d4578 # tag=v6
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).

3
.gitignore vendored
View File

@@ -3,6 +3,5 @@
local.properties
/.idea
.gradle
/.gradle
build/
captures/

View File

@@ -1,15 +0,0 @@
# Build files
/.github/workflows/ @nielsvanvelzen
/.github/dependabot.yml @nielsvanvelzen
/gradle/ @nielsvanvelzen
/buildSrc/ @nielsvanvelzen
settings.gradle.ts @nielsvanvelzen
build.gradle.kts @nielsvanvelzen
gradle.properties @nielsvanvelzen
# Linting
android-lint.xml @nielsvanvelzen
detekt.yaml @nielsvanvelzen
# Playback code
/playback/ @nielsvanvelzen

View File

@@ -9,8 +9,6 @@
- [AndreasGB](https://github.com/andreasgb)
- [joern-h](https://github.com/joern-h)
- [Niels van Velzen](https://github.com/nielsvanvelzen)
- [GodTamIt](https://github.com/GodTamIt)
- [sparky3387](https://github.com/sparky3387)
# Emby Contributors

38
Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
FROM debian:9
# Docker build arguments
ARG SOURCE_DIR=/repo
ARG ARTIFACT_DIR=/dist
ARG ANDROID_DIR=/usr/lib/android-sdk
# Docker run environment
ENV SOURCE_DIR=/repo
ENV ARTIFACT_DIR=/dist
ENV ANDROID_DIR=/usr/lib/android-sdk
# Prepare Debian build environment
RUN echo "deb http://deb.debian.org/debian stretch-backports main" >> /etc/apt/sources.list.d/backports.list \
&& apt-get update \
&& apt-get install -y mmv wget unzip git \
&& apt-get install -t stretch-backports -y android-sdk openjdk-8-jdk
# Install Android repository tools
RUN rm -rf ${ANDROID_DIR}/tools \
&& wget http://dl-ssl.google.com/android/repository/tools_r25.2.3-linux.zip -O tools.zip \
&& unzip tools.zip -d ${ANDROID_DIR}/ \
&& rm -f tools.zip
# Install SDK tools
# `yes` is required to accept licenses:
# https://stackoverflow.com/questions/38096225/automatically-accept-all-sdk-licences/42125740#42125740
# There will be no output to the screen while this runs - expect a delay of up to several minutes on the license
RUN yes | ${ANDROID_DIR}/tools/bin/sdkmanager "platform-tools" "platforms;android-27" "build-tools;27.0.3" "extras;android;m2repository" "extras;google;m2repository"
RUN ln -sf ${SOURCE_DIR}/docker-build.sh /docker-build.sh
# Prepare artifact volume
VOLUME ${ARTIFACT_DIR}/
# Copy repository
COPY . ${SOURCE_DIR}/
# Set docker-build entrypoint
ENTRYPOINT ["/docker-build.sh"]

11
LICENSE
View File

@@ -1,7 +1,7 @@
GNU GENERAL PUBLIC LICENSE
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
Copyright (C) 1989, 1991 Free Software Foundation, Inc., <http://fsf.org/>
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.
@@ -290,8 +290,8 @@ 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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
{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
@@ -329,7 +329,7 @@ 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
{signature of Ty Coon}, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
@@ -337,3 +337,4 @@ 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,5 +1,5 @@
<h1 align="center">Jellyfin Android TV</h1>
<h3 align="center">Part of the <a href="https://jellyfin.org">Jellyfin Project</a></h3>
<h3 align="center">Part of the <a href="https://jellyfin.media">Jellyfin Project</a></h3>
---
@@ -12,8 +12,8 @@
<a href="https://github.com/jellyfin/jellyfin-androidtv/releases">
<img alt="Current Release" src="https://img.shields.io/github/release/jellyfin/jellyfin-androidtv.svg"/>
</a>
<a href="https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/">
<img alt="Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin-android/-/jellyfin-androidtv/svg-badge.svg"/>
<a href="https://translate.jellyfin.org/projects/jellyfin/jellyfin-androidtv/?utm_source=widget">
<img src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-androidtv/svg-badge.svg" alt="Translation status" />
</a>
<br/>
<a href="https://opencollective.com/jellyfin">
@@ -22,6 +22,9 @@
<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://forum.jellyfin.org">
<img alt="Discuss on our Forum" src="https://img.shields.io/discourse/https/forum.jellyfin.org/users.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>
@@ -30,57 +33,18 @@
</a>
<br/>
<a href="https://play.google.com/store/apps/details?id=org.jellyfin.androidtv">
<img width="153" alt="Jellyfin on Google Play" src="https://jellyfin.org/images/store-icons/google-play.png"/>
<img width="200" src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play"/>
</a>
<br/>
<a href="https://www.amazon.com/gp/aw/d/B07TX7Z725">
<img width="153" alt="Jellyfin on Amazon Appstore" src="https://jellyfin.org/images/store-icons/amazon.png"/>
<img width="170" src="https://images-na.ssl-images-amazon.com/images/G/01/mobile-apps/devportal2/res/images/amazon-appstore-badge-english-black.png" alt="Available at Amazon Appstore"/>
</a>
<br/>
<a href="https://repo.jellyfin.org/releases/client/androidtv/">Download archive</a>
</p>
Jellyfin Android TV is a Jellyfin client for Android TV, Nvidia Shield, and Amazon Fire TV devices.
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.
Jellyfin Android TV is a Jellyfin client for Android TV, nVidia Shield, and Amazon Fire TV devices. 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. Translations can be improved very easily from our <a href="https://translate.jellyfin.org/projects/jellyfin/jellyfin-androidtv">Weblate</a> instance. Look through the following graphic to see if your native language could use some work!
## Translating
Translations can be improved very easily from our
[Weblate](https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv) 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-androidtv/multi-auto.svg"/>
<a href="https://translate.jellyfin.org/engage/jellyfin/?utm_source=widget">
<img src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-androidtv/multi-auto.svg" alt="Detailed Translation Status"/>
</a>
## Build Process
### Dependencies
- Android Studio
### Build
1. Clone or download this repository
```sh
git clone https://github.com/jellyfin/jellyfin-androidtv.git
cd jellyfin-androidtv
```
2. Open the project in Android Studio and run it from there or build an APK directly through Gradle:
```sh
./gradlew assembleDebug
```
Add the Android SDK to your PATH environment variable or create the ANDROID_SDK_ROOT variable for
this to work.
### Deploy to device/emulator
```sh
./gradlew installDebug
```
*You can also replace the "Debug" with "Release" to get an optimized release binary.*

View File

@@ -1,21 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<lint>
<issue id="BinaryOperationInTimber" severity="error" />
<issue id="ConvertToWebp" severity="warning" />
<issue id="DuplicateStrings" severity="ignore" />
<issue id="EasterEgg" severity="fatal" />
<issue id="ExtraTranslation" severity="warning" />
<issue id="FragmentTagUsage" severity="error" />
<issue id="GradleDependency" severity="ignore" />
<issue id="KotlinPropertyAccess" severity="warning" />
<issue id="LambdaLast" severity="warning" />
<issue id="LogNotTimber" severity="error" />
<issue id="MangledCRLF" severity="warning" />
<issue id="NegativeMargin" severity="warning" />
<issue id="NoHardKeywords" severity="warning" />
<issue id="Registered" severity="warning" />
<issue id="RequiredSize" severity="error" />
<issue id="Typos" severity="ignore" />
<issue id="UnknownNullness" severity="informational" />
<issue id="UnusedIds" severity="ignore" />
</lint>

71
app/build.gradle Normal file
View File

@@ -0,0 +1,71 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
def version = "0.11.1"
android {
compileSdkVersion 28
buildToolsVersion "28.0.3"
compileOptions {
sourceCompatibility 1.8
targetCompatibility 1.8
}
defaultConfig {
minSdkVersion 21
targetSdkVersion 28
versionCode 906
versionName version
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
debug {
applicationIdSuffix ".debug"
debuggable true
}
}
}
dependencies {
def acraVersion = '5.4.0'
implementation fileTree(include: ['*.jar'], dir: 'libs')
// This is split like this because this is a modular gradle project on jitpack
// see https://github.com/jitpack/gradle-modular
// The split allows dependency substitution to work in the settings.gradle file,
// at least for the 'library' part. 'android' part is still broken somehow, but this
// is better than nothing.
implementation 'com.github.jellyfin.jellyfin-apiclient-java:android:0.5.0'
implementation 'com.github.jellyfin.jellyfin-apiclient-java:library:0.5.0'
implementation 'com.amazon.android:exoplayer:2.10.6'
implementation 'androidx.recyclerview:recyclerview:1.0.0'
implementation 'androidx.leanback:leanback:1.0.0'
implementation 'androidx.appcompat:appcompat:1.0.2'
implementation 'androidx.palette:palette:1.0.0'
implementation 'com.squareup.picasso:picasso:2.3.2'
implementation 'com.github.bumptech.glide:glide:3.7.0'
implementation 'com.squareup.okhttp:okhttp:2.7.0'
implementation 'com.squareup.okhttp:okhttp-urlconnection:2.7.0'
implementation 'com.google.guava:guava:18.0'
implementation 'com.flaviofaria:kenburnsview:1.0.6'
implementation 'org.videolan.android:libvlc-all:3.1.12'
implementation "ch.acra:acra-http:$acraVersion"
implementation "ch.acra:acra-dialog:$acraVersion"
implementation "ch.acra:acra-limiter:$acraVersion"
testImplementation 'junit:junit:4.12'
implementation "androidx.core:core-ktx:1.1.0"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.tvprovider:tvprovider:1.0.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.2'
}
repositories {
mavenCentral()
}

View File

@@ -1,176 +0,0 @@
plugins {
id("com.android.application")
kotlin("android")
alias(libs.plugins.kotlin.ksp)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.aboutlibraries)
}
android {
namespace = "org.jellyfin.androidtv"
compileSdk = 33
defaultConfig {
minSdk = 21
targetSdk = 33
// Release version
applicationId = namespace
versionName = project.getVersionName()
versionCode = getVersionCode(versionName!!)
setProperty("archivesBaseName", "jellyfin-androidtv-v$versionName")
}
sourceSets["main"].java.srcDirs("src/main/kotlin")
sourceSets["test"].java.srcDirs("src/test/kotlin")
buildFeatures {
viewBinding = true
compose = true
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get()
}
buildTypes {
val release by getting {
isMinifyEnabled = false
// Set package names used in various XML files
resValue("string", "app_id", namespace!!)
resValue("string", "app_search_suggest_authority", "${namespace}.content")
resValue("string", "app_search_suggest_intent_data", "content://${namespace}.content/intent")
// Set flavored application name
resValue("string", "app_name", "@string/app_name_release")
buildConfigField("boolean", "DEVELOPMENT", "false")
}
val debug by getting {
// Use different application id to run release and debug at the same time
applicationIdSuffix = ".debug"
// Set package names used in various XML files
resValue("string", "app_id", namespace + applicationIdSuffix)
resValue("string", "app_search_suggest_authority", "${namespace + applicationIdSuffix}.content")
resValue("string", "app_search_suggest_intent_data", "content://${namespace + applicationIdSuffix}.content/intent")
// Set flavored application name
resValue("string", "app_name", "@string/app_name_debug")
buildConfigField("boolean", "DEVELOPMENT", (defaultConfig.versionCode!! < 100).toString())
}
}
lint {
lintConfig = file("$rootDir/android-lint.xml")
abortOnError = false
sarifReport = true
checkDependencies = true
}
testOptions.unitTests.all {
it.useJUnitPlatform()
}
}
aboutLibraries {
// Remove the "generated" timestamp to allow for reproducible builds
excludeFields = arrayOf("generated")
}
val versionTxt by tasks.registering {
val path = buildDir.resolve("version.txt")
doLast {
val versionString = "v${android.defaultConfig.versionName}=${android.defaultConfig.versionCode}"
logger.info("Writing [$versionString] to $path")
path.writeText("$versionString\n")
}
}
dependencies {
// Jellyfin
implementation(projects.playback.core)
implementation(projects.playback.exoplayer)
implementation(projects.playback.jellyfin)
implementation(projects.playback.ui)
implementation(projects.preference)
implementation(libs.jellyfin.apiclient)
implementation(libs.jellyfin.sdk) {
// Change version if desired
val sdkVersion = findProperty("sdk.version")?.toString()
when (sdkVersion) {
"local" -> version { strictly("latest-SNAPSHOT") }
"snapshot" -> version { strictly("master-SNAPSHOT") }
"unstable-snapshot" -> version { strictly("openapi-unstable-SNAPSHOT") }
}
}
// Kotlin
implementation(libs.kotlinx.coroutines)
implementation(libs.kotlinx.serialization.json)
// Android(x)
implementation(libs.androidx.core)
implementation(libs.androidx.activity)
implementation(libs.androidx.fragment)
implementation(libs.androidx.leanback.core)
implementation(libs.androidx.leanback.preference)
implementation(libs.androidx.preference)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.tvprovider)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.work.runtime)
implementation(libs.bundles.androidx.lifecycle)
implementation(libs.androidx.window)
implementation(libs.androidx.cardview)
implementation(libs.androidx.startup)
implementation(libs.bundles.androidx.compose)
// Dependency Injection
implementation(libs.bundles.koin)
// Media players
implementation(libs.exoplayer)
implementation(libs.jellyfin.exoplayer.ffmpegextension)
implementation(libs.libvlc)
// Markdown
implementation(libs.bundles.markwon)
// Image utility
implementation(libs.blurhash)
implementation(libs.glide.core)
ksp(libs.glide.ksp)
implementation(libs.kenburnsview)
// Crash Reporting
implementation(libs.bundles.acra)
// Licenses
implementation(libs.aboutlibraries)
// Logging
implementation(libs.timber)
implementation(libs.slf4j.timber)
// Debugging
if (getProperty("leakcanary.enable")?.toBoolean() == true)
debugImplementation(libs.leakcanary)
// Compatibility (desugaring)
coreLibraryDesugaring(libs.android.desugar)
// Testing
testImplementation(libs.kotest.runner.junit5)
testImplementation(libs.kotest.assertions)
testImplementation(libs.mockk)
}

17
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,17 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in C:\Users\Eric\AppData\Local\Android\android-sdk/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="logo_gradient_start">#F2364D</color>
<color name="logo_gradient_stop">#FDC92F</color>
<color name="logo_background">#0A0A0A</color>
<color name="logo_text">#FFFFFF</color>
</resources>

View File

@@ -1,36 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
package="org.jellyfin.androidtv" >
<!-- Android TV Integration -->
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<!-- AccountManager permissions -->
<uses-permission
android:name="android.permission.AUTHENTICATE_ACCOUNTS"
android:maxSdkVersion="22" />
<uses-permission
android:name="android.permission.GET_ACCOUNTS"
android:maxSdkVersion="22" />
<uses-permission
android:name="android.permission.MANAGE_ACCOUNTS"
android:maxSdkVersion="22" />
<!-- Generic permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission
android:name="android.permission.ACCESS_WIFI_STATE"
tools:ignore="LeanbackUsesWifi" />
<!-- Search permissions (voice to text) -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Device feature requirements -->
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Needed for ChannelManager on older devices (< API 23) -->
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
@@ -39,120 +22,71 @@
android:required="false" />
<application
android:name=".JellyfinApplication"
android:allowBackup="true"
android:appCategory="video"
android:banner="@drawable/app_banner"
android:dataExtractionRules="@xml/backup_rules"
android:fullBackupContent="@xml/backup_content"
android:icon="@drawable/app_icon"
android:name=".TvApp"
android:banner="@drawable/app_logo"
android:icon="@drawable/app_icon_large"
android:label="@string/app_name"
android:largeHeap="true"
android:roundIcon="@drawable/app_icon_round"
android:supportsRtl="true"
android:theme="@style/Theme.Jellyfin"
android:usesCleartextTraffic="true">
<service
android:name=".auth.service.AuthenticatorService"
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
<!-- Screensaver -->
<service
android:name=".integration.dream.LibraryDreamService"
android:exported="true"
android:permission="android.permission.BIND_DREAM_SERVICE">
<intent-filter>
<action android:name="android.service.dreams.DreamService" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</service>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
android:initOrder="1000"
tools:node="merge">
<meta-data
android:name="org.jellyfin.androidtv.LogInitializer"
android:value="androidx.startup" />
<meta-data
android:name="org.jellyfin.androidtv.di.KoinInitializer"
android:value="androidx.startup" />
<meta-data
android:name="org.jellyfin.androidtv.SessionInitializer"
android:value="androidx.startup" />
<meta-data
android:name="org.jellyfin.androidtv.ProcessLifecycleInitializer"
android:value="androidx.startup" />
</provider>
<provider
android:name=".integration.MediaContentProvider"
android:authorities="${applicationId}.content"
android:exported="true"
android:initOrder="10" />
<provider
android:name=".integration.provider.ImageProvider"
android:authorities="${applicationId}.integration.provider.ImageProvider"
android:exported="true"
android:initOrder="10" />
<!-- Authentication -->
android:allowBackup="false"
android:usesCleartextTraffic="true"
tools:ignore="GoogleAppIndexingWarning">
<activity
android:name=".ui.startup.StartupActivity"
android:exported="true"
android:noHistory="true"
android:screenOrientation="landscape"
android:windowSoftInputMode="adjustNothing">
android:name=".browsing.MainActivity"
android:label="@string/app_name"
android:logo="@drawable/app_logo"
android:screenOrientation="landscape" />
<activity
android:name=".startup.StartupActivity"
android:label="@string/app_name"
android:logo="@drawable/app_logo"
android:noHistory="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.SEARCH" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.searchable"
android:resource="@xml/searchable" />
</activity>
<activity-alias
android:name=".startup.StartupActivity"
android:targetActivity=".ui.startup.StartupActivity" />
<!-- Main application -->
<activity
android:name=".ui.browsing.MainActivity"
android:screenOrientation="landscape"
android:windowSoftInputMode="adjustNothing" />
android:name=".startup.SelectUserActivity"
android:label="@string/title_activity_select_user" />
<activity
android:name=".playback.PlaybackOverlayActivity"
android:label="PlaybackOverlayActivity" />
<activity android:name=".browsing.GenericFolderActivity" />
<activity android:name=".browsing.UserViewActivity" />
<activity android:name=".startup.SelectServerActivity" />
<activity android:name=".browsing.CollectionActivity" />
<activity android:name=".browsing.ByLetterActivity" />
<activity android:name=".browsing.ByGenreActivity" />
<activity android:name=".settings.SettingsActivity" />
<activity android:name=".browsing.SuggestedMoviesActivity" />
<activity android:name=".browsing.BrowsePersonsActivity" />
<activity android:name=".search.SearchActivity" />
<activity android:name=".details.FullDetailsActivity" />
<activity android:name=".livetv.LiveTvGuideActivity" />
<activity android:name=".browsing.GenericGridActivity" />
<activity android:name=".browsing.BrowseRecordingsActivity" />
<activity android:name=".browsing.BrowseScheduleActivity" />
<activity android:name=".details.PhotoPlayerActivity" />
<activity android:name=".details.ItemListActivity" />
<activity android:name=".playback.AudioNowPlayingActivity" />
<activity android:name=".playback.ExternalPlayerActivity" />
<activity
android:name=".startup.DpadPwActivity"
android:label="@string/title_activity_dpad_pw" >
</activity>
<receiver android:name=".util.RemoteControlReceiver">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<activity
android:name=".ui.preference.PreferencesActivity"
android:theme="@style/Theme.Jellyfin.Preferences" />
<!-- Playback related activities -->
<activity
android:name=".ui.playback.PlaybackOverlayActivity"
android:screenOrientation="landscape" />
<activity android:name=".ui.playback.ExternalPlayerActivity" />
<activity
android:name=".ui.playback.nextup.NextUpActivity"
android:noHistory="true"
android:screenOrientation="landscape" />
<!-- Playback Rewrite - Temporary -->
<activity android:name=".ui.playback.rewrite.PlaybackForwardingActivity" />
android:name=".details.ExpandedTextActivity"
android:label="@string/title_activity_expanded_text" />
</application>
</manifest>

Binary file not shown.

View File

@@ -1,87 +0,0 @@
package org.jellyfin.androidtv
import android.app.Application
import android.content.Context
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.work.BackoffPolicy
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.await
import com.bumptech.glide.Glide
import com.vanniktech.blurhash.BlurHash
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.acra.ACRA
import org.jellyfin.androidtv.data.eventhandling.SocketHandler
import org.jellyfin.androidtv.data.repository.NotificationsRepository
import org.jellyfin.androidtv.integration.LeanbackChannelWorker
import org.jellyfin.androidtv.telemetry.TelemetryService
import org.jellyfin.androidtv.util.AutoBitrate
import org.koin.android.ext.android.inject
import java.util.concurrent.TimeUnit
@Suppress("unused")
class JellyfinApplication : Application() {
override fun onCreate() {
super.onCreate()
// Don't run in ACRA service
if (ACRA.isACRASenderServiceProcess()) return
val notificationsRepository by inject<NotificationsRepository>()
notificationsRepository.addDefaultNotifications()
}
/**
* Called from the StartupActivity when the user session is started.
*/
suspend fun onSessionStart() = withContext(Dispatchers.IO) {
val workManager by inject<WorkManager>()
val autoBitrate by inject<AutoBitrate>()
val socketListener by inject<SocketHandler>()
// Update background worker
launch {
// Cancel all current workers
workManager.cancelAllWork().await()
// Recreate periodic workers
workManager.enqueueUniquePeriodicWork(
LeanbackChannelWorker.PERIODIC_UPDATE_REQUEST_NAME,
ExistingPeriodicWorkPolicy.REPLACE,
PeriodicWorkRequestBuilder<LeanbackChannelWorker>(1, TimeUnit.HOURS)
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.MINUTES)
.build()
).await()
}
// Update WebSockets
launch { socketListener.updateSession() }
// Detect auto bitrate
// running in a different scope to prevent slow startups
ProcessLifecycleOwner.get().lifecycleScope.launch { autoBitrate.detect() }
}
override fun onLowMemory() {
super.onLowMemory()
BlurHash.clearCache()
Glide.with(this).onLowMemory()
}
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
Glide.with(this).onTrimMemory(level)
}
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
TelemetryService.init(this)
}
}

View File

@@ -1,22 +0,0 @@
package org.jellyfin.androidtv
import android.content.Context
import android.util.Log
import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.module.AppGlideModule
import com.bumptech.glide.request.RequestOptions
@GlideModule
class JellyfinGlideModule : AppGlideModule() {
override fun applyOptions(context: Context, builder: GlideBuilder): Unit = with(builder) {
setDefaultRequestOptions(
// Set default disk cache strategy
RequestOptions().diskCacheStrategy(DiskCacheStrategy.RESOURCE)
)
// Silence image load errors
setLogLevel(Log.ERROR)
}
}

View File

@@ -1,28 +0,0 @@
package org.jellyfin.androidtv
import android.content.Context
import androidx.startup.Initializer
import timber.log.Timber
class LogInitializer : Initializer<Unit> {
override fun create(context: Context) {
// Enable improved logging for leaking resources
// https://wh0.github.io/2020/08/12/closeguard.html
if (BuildConfig.DEBUG) {
try {
Class.forName("dalvik.system.CloseGuard")
.getMethod("setEnabled", Boolean::class.javaPrimitiveType)
.invoke(null, true)
} catch (e: ReflectiveOperationException) {
@Suppress("TooGenericExceptionThrown")
throw RuntimeException(e)
}
}
// Initialize the logging library
Timber.plant(Timber.DebugTree())
Timber.i("Debug tree planted")
}
override fun dependencies() = emptyList<Class<out Initializer<*>>>()
}

View File

@@ -1,50 +0,0 @@
package org.jellyfin.androidtv
import android.app.Application
import android.content.Context
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.startup.AppInitializer
import androidx.startup.Initializer
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.auth.repository.SessionRepository
import org.jellyfin.androidtv.di.KoinInitializer
import timber.log.Timber
@Suppress("unused")
class ProcessLifecycleInitializer : Initializer<Unit> {
override fun create(context: Context) {
val koin = AppInitializer.getInstance(context)
.initializeComponent(KoinInitializer::class.java)
.koin
// Register application lifecycle events
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
/**
* Called by the Process Lifecycle when the app is created. It is called after [onCreate].
*/
override fun onCreate(owner: LifecycleOwner) {
// Register activity lifecycle callbacks
val callbacks = koin.getAll<Application.ActivityLifecycleCallbacks>()
Timber.i("Registering ${callbacks.size} ActivityLifecycleCallbacks")
val app = context.applicationContext as Application
callbacks.forEach { callback -> app.registerActivityLifecycleCallbacks(callback) }
}
/**
* Called by the Process Lifecycle when the app is activated in the foreground (activity opened).
*/
override fun onStart(owner: LifecycleOwner) {
Timber.i("Process lifecycle started")
owner.lifecycleScope.launch {
koin.get<SessionRepository>().restoreSession()
}
}
})
}
override fun dependencies() = listOf(KoinInitializer::class.java)
}

View File

@@ -1,26 +0,0 @@
package org.jellyfin.androidtv
import android.content.Context
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.startup.AppInitializer
import androidx.startup.Initializer
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.auth.repository.SessionRepository
import org.jellyfin.androidtv.di.KoinInitializer
@Suppress("unused")
class SessionInitializer : Initializer<Unit> {
override fun create(context: Context) {
val koin = AppInitializer.getInstance(context)
.initializeComponent(KoinInitializer::class.java)
.koin
// Restore system session
ProcessLifecycleOwner.get().lifecycleScope.launch {
koin.get<SessionRepository>().restoreSession()
}
}
override fun dependencies() = listOf(KoinInitializer::class.java)
}

View File

@@ -0,0 +1,578 @@
package org.jellyfin.androidtv;
import android.app.Activity;
import android.app.Application;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.media.AudioManager;
import android.preference.PreferenceManager;
import org.acra.ACRA;
import org.acra.annotation.AcraCore;
import org.acra.annotation.AcraDialog;
import org.acra.annotation.AcraHttpSender;
import org.acra.annotation.AcraLimiter;
import org.acra.sender.HttpSender;
import org.jellyfin.androidtv.base.BaseActivity;
import org.jellyfin.androidtv.livetv.TvManager;
import org.jellyfin.androidtv.model.DisplayPriorityType;
import org.jellyfin.androidtv.model.LogonCredentials;
import org.jellyfin.androidtv.playback.ExternalPlayerActivity;
import org.jellyfin.androidtv.playback.MediaManager;
import org.jellyfin.androidtv.playback.PlaybackController;
import org.jellyfin.androidtv.playback.PlaybackManager;
import org.jellyfin.androidtv.playback.PlaybackOverlayActivity;
import org.jellyfin.androidtv.search.SearchActivity;
import org.jellyfin.androidtv.util.DeviceUtils;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.apiclient.interaction.ApiClient;
import org.jellyfin.apiclient.interaction.EmptyResponse;
import org.jellyfin.apiclient.interaction.IConnectionManager;
import org.jellyfin.apiclient.interaction.Response;
import org.jellyfin.apiclient.interaction.VolleyHttpClient;
import org.jellyfin.apiclient.logging.AndroidLogger;
import org.jellyfin.apiclient.model.configuration.ServerConfiguration;
import org.jellyfin.apiclient.model.dto.BaseItemDto;
import org.jellyfin.apiclient.model.dto.BaseItemType;
import org.jellyfin.apiclient.model.dto.UserDto;
import org.jellyfin.apiclient.model.entities.DisplayPreferences;
import org.jellyfin.apiclient.model.logging.ILogger;
import org.jellyfin.apiclient.model.serialization.GsonJsonSerializer;
import java.util.Calendar;
import java.util.HashMap;
import androidx.core.content.ContextCompat;
import androidx.palette.graphics.Palette;
@AcraCore(buildConfigClass = BuildConfig.class)
@AcraHttpSender(
uri = "https://collector.tracepot.com/a2eda9d9",
httpMethod = HttpSender.Method.POST
)
@AcraDialog(
resTitle = R.string.acra_dialog_title,
resText = R.string.acra_dialog_text,
resTheme = R.style.Theme_Jellyfin
)
@AcraLimiter
public class TvApp extends Application {
// The minimum supported server version. Trying to connect to an older server will display an error.
public static final String MINIMUM_SERVER_VERSION = "10.3.0";
public static final String CREDENTIALS_PATH = "org.jellyfin.androidtv.login.json";
public static final int LIVE_TV_GUIDE_OPTION_ID = 1000;
public static final int LIVE_TV_RECORDINGS_OPTION_ID = 2000;
public static final int VIDEO_QUEUE_OPTION_ID = 3000;
public static final int LIVE_TV_SCHEDULE_OPTION_ID = 4000;
public static final int LIVE_TV_SERIES_OPTION_ID = 5000;
private static final String TAG = "Jellyfin-AndroidTV";
private ILogger logger;
private IConnectionManager connectionManager;
private PlaybackManager playbackManager;
private GsonJsonSerializer serializer;
private static TvApp app;
private UserDto currentUser;
private BaseItemDto currentPlayingItem;
private BaseItemDto lastPlayedItem;
private PlaybackController playbackController;
private ApiClient loginApiClient;
private AudioManager audioManager;
private VolleyHttpClient httpClient;
private int autoBitrate;
private String directItemId;
private Typeface roboto;
private HashMap<String, DisplayPreferences> displayPrefsCache = new HashMap<>();
private String lastDeletedItemId = "";
private ServerConfiguration serverConfiguration;
private int maxRemoteBitrate = -1;
private Calendar lastPlayback = Calendar.getInstance();
private Calendar lastMoviePlayback = Calendar.getInstance();
private Calendar lastTvPlayback = Calendar.getInstance();
private Calendar lastLibraryChange = Calendar.getInstance();
private long lastVideoQueueChange = System.currentTimeMillis();
private long lastFavoriteUpdate = System.currentTimeMillis();
private long lastMusicPlayback = System.currentTimeMillis();
private long lastUserInteraction = System.currentTimeMillis();
private GradientDrawable currentBackgroundGradient;
private boolean audioMuted;
private boolean playingIntros;
private DisplayPriorityType displayPriority = DisplayPriorityType.Movies;
private BaseActivity currentActivity;
private LogonCredentials configuredAutoCredentials;
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
ACRA.init(this);
}
@Override
public void onCreate() {
super.onCreate();
logger = new AndroidLogger(TAG);
app = (TvApp) getApplicationContext();
audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
roboto = Typeface.createFromAsset(getAssets(), "fonts/Roboto-Light.ttf");
setCurrentBackgroundGradient(new int[] {ContextCompat.getColor(this, R.color.lb_default_brand_color_dark), ContextCompat.getColor(this, R.color.lb_default_brand_color)});
logger.Info("Application object created");
}
public static TvApp getApplication() {
return app;
}
public ILogger getLogger() {
return logger;
}
public void setLogger(ILogger value) {
logger = value;
}
public Typeface getDefaultFont() { return roboto; }
public IConnectionManager getConnectionManager() {
return connectionManager;
}
public void setConnectionManager(IConnectionManager connectionManager) {
this.connectionManager = connectionManager;
}
public UserDto getCurrentUser() {
return currentUser;
}
public void setCurrentUser(UserDto currentUser) {
this.currentUser = currentUser;
TvManager.clearCache();
this.displayPrefsCache = new HashMap<>();
}
public GsonJsonSerializer getSerializer() {
return serializer;
}
public void setSerializer(GsonJsonSerializer serializer) {
this.serializer = serializer;
}
public ApiClient getApiClient() {
return currentUser != null ? connectionManager.GetApiClient(currentUser) : null;
}
public BaseItemDto getCurrentPlayingItem() {
return currentPlayingItem;
}
public BaseActivity getCurrentActivity() {
return currentActivity;
}
public void setCurrentActivity(BaseActivity activity) {
currentActivity = activity;
}
public void setCurrentPlayingItem(BaseItemDto currentPlayingItem) {
this.currentPlayingItem = currentPlayingItem;
}
public ApiClient getLoginApiClient() {
return loginApiClient;
}
public void setLoginApiClient(ApiClient loginApiClient) {
this.loginApiClient = loginApiClient;
}
public void setAudioMuted(boolean value) {
audioMuted = value;
getLogger().Info("Setting mute state to: "+audioMuted);
if (DeviceUtils.is60()) {
audioManager.adjustVolume(audioMuted ? AudioManager.ADJUST_MUTE : AudioManager.ADJUST_UNMUTE, 0);
} else {
audioManager.setStreamMute(AudioManager.STREAM_MUSIC, audioMuted);
}
}
public boolean isAudioMuted() { return audioMuted; }
public PlaybackController getPlaybackController() {
return playbackController;
}
public void setPlaybackController(PlaybackController playbackController) {
this.playbackController = playbackController;
}
public void showSearch(final Activity activity, boolean musicOnly) {
Intent intent = new Intent(activity, SearchActivity.class);
intent.putExtra("MusicOnly", musicOnly);
activity.startActivity(intent);
}
public void showMessage(String title, String msg) {
if (currentActivity != null) {
currentActivity.showMessage(title, msg);
}
}
public void showMessage(String title, String msg, int timeout, int iconResource) {
if (currentActivity != null) {
currentActivity.showMessage(title, msg, timeout, iconResource, null);
}
}
public LogonCredentials getConfiguredAutoCredentials() {
return configuredAutoCredentials;
}
public void setConfiguredAutoCredentials(LogonCredentials configuredAutoCredentials) {
this.configuredAutoCredentials = configuredAutoCredentials;
}
public SharedPreferences getPrefs() {
return PreferenceManager.getDefaultSharedPreferences(this);
}
public SharedPreferences getSystemPrefs() {
return getSharedPreferences("systemprefs", MODE_PRIVATE);
}
public String getConfigVersion() {
return getSystemPrefs().getString("sys_pref_config_version", "2");
}
public boolean getIsAutoLoginConfigured() {
return getPrefs().getString("pref_login_behavior", "0").equals("1") && getConfiguredAutoCredentials().getServerInfo().getId() != null;
}
public boolean useExternalPlayer(BaseItemType itemType) {
switch (itemType) {
case Movie:
case Episode:
case Video:
case Series:
case Recording:
return getPrefs().getString("pref_video_player", "auto").equals("external");
case TvChannel:
case Program:
return getPrefs().getBoolean("pref_live_tv_use_external", false);
default:
return false;
}
}
public Class getPlaybackActivityClass(BaseItemType itemType) {
return useExternalPlayer(itemType) ? ExternalPlayerActivity.class : PlaybackOverlayActivity.class;
}
public Calendar getLastMoviePlayback() {
return lastMoviePlayback.after(lastPlayback) ? lastMoviePlayback : lastPlayback;
}
public void setLastMoviePlayback(Calendar lastMoviePlayback) {
this.lastMoviePlayback = lastMoviePlayback;
this.lastPlayback = lastMoviePlayback;
}
public void setLastFavoriteUpdate(long time) { lastFavoriteUpdate = time; }
public long getLastFavoriteUpdate() { return lastFavoriteUpdate; }
public void setLastMusicPlayback(long time) { lastMusicPlayback = time; }
public long getLastMusicPlayback() { return lastMusicPlayback; }
public boolean directStreamLiveTv() { return getPrefs().getBoolean("pref_live_direct", true); }
public void setDirectStreamLiveTv(boolean value) { getPrefs().edit().putBoolean("pref_live_direct", value).commit(); }
public boolean useVlcForLiveTv() { return getPrefs().getBoolean("pref_enable_vlc_livetv", false); }
public int getResumePreroll() {
try {
return Integer.parseInt(getPrefs().getString("pref_resume_preroll","0")) * 1000;
} catch (Exception e) {
return 0;
}
}
public Calendar getLastTvPlayback() {
return lastTvPlayback.after(lastPlayback) ? lastTvPlayback : lastPlayback;
}
public void setLastTvPlayback(Calendar lastTvPlayback) {
this.lastTvPlayback = lastTvPlayback;
this.lastPlayback = lastTvPlayback;
}
public Calendar getLastLibraryChange() {
return lastLibraryChange;
}
public void setLastLibraryChange(Calendar lastLibraryChange) {
this.lastLibraryChange = lastLibraryChange;
}
public Calendar getLastPlayback() {
return lastPlayback;
}
public void setLastPlayback(Calendar lastPlayback) {
this.lastPlayback = lastPlayback;
}
public long getLastUserInteraction() {
return lastUserInteraction;
}
public void setLastUserInteraction(long lastUserInteraction) {
this.lastUserInteraction = lastUserInteraction;
}
public boolean canManageRecordings() {
return currentUser != null && currentUser.getPolicy().getEnableLiveTvManagement();
}
public PlaybackManager getPlaybackManager() {
return playbackManager;
}
public void setPlaybackManager(PlaybackManager playbackManager) {
this.playbackManager = playbackManager;
}
public Drawable getDrawableCompat(int id) {
// if (Build.VERSION.SDK_INT >= 21) {
// return getDrawable(id);
// }
return getResources().getDrawable(id);
}
public boolean isPlayingVideo() {
return playbackController != null && currentActivity != null && currentActivity instanceof PlaybackOverlayActivity;
}
public void stopPlayback() {
if (isPlayingVideo()) {
currentActivity.finish();
} else if (MediaManager.isPlayingAudio()) {
MediaManager.stopAudio();
}
}
public void pausePlayback() {
if (MediaManager.isPlayingAudio()) {
MediaManager.pauseAudio();
} else if (isPlayingVideo()) {
playbackController.playPause();
}
}
public void unPausePlayback() {
if (isPlayingVideo()) {
playbackController.playPause();
} else if (MediaManager.hasAudioQueueItems()) {
MediaManager.resumeAudio();
}
}
public void playbackNext() {
if (isPlayingVideo()) {
playbackController.next();
} else if (MediaManager.hasAudioQueueItems()) {
MediaManager.nextAudioItem();
}
}
public void playbackPrev() {
if (isPlayingVideo()) {
playbackController.prev();
} else if (MediaManager.hasAudioQueueItems()) {
MediaManager.prevAudioItem();
}
}
public void playbackSeek(int pos) {
if (isPlayingVideo()) {
playbackController.seek(pos);
}
}
public void playbackJump() {
if (isPlayingVideo()) {
playbackController.skip(30000);
}
}
public void playbackJumpBack() {
if (playbackController != null) {
playbackController.skip(-11000);
}
}
public DisplayPreferences getCachedDisplayPrefs(String key) {
return displayPrefsCache.containsKey(key) ? displayPrefsCache.get(key) : new DisplayPreferences();
}
public void updateDisplayPrefs(DisplayPreferences preferences) {
updateDisplayPrefs("ATV", preferences);
}
public void updateDisplayPrefs(String app, DisplayPreferences preferences) {
displayPrefsCache.put(preferences.getId(), preferences);
getApiClient().UpdateDisplayPreferencesAsync(preferences, getCurrentUser().getId(), app, new EmptyResponse());
logger.Debug("Display prefs updated for "+preferences.getId()+" isFavorite: "+preferences.getCustomPrefs().get("FavoriteOnly"));
}
public void getDisplayPrefsAsync(String key, Response<DisplayPreferences> response) {
getDisplayPrefsAsync(key, "ATV", response);
}
public void getDisplayPrefsAsync(final String key, String app, final Response<DisplayPreferences> outerResponse) {
if (displayPrefsCache.containsKey(key)) {
logger.Debug("Display prefs loaded from cache "+key);
outerResponse.onResponse(displayPrefsCache.get(key));
} else {
getApiClient().GetDisplayPreferencesAsync(key, getCurrentUser().getId(), app, new Response<DisplayPreferences>(){
@Override
public void onResponse(DisplayPreferences response) {
if (response.getSortBy() == null) response.setSortBy("SortName");
if (response.getCustomPrefs() == null) response.setCustomPrefs(new HashMap<String, String>());
displayPrefsCache.put(key, response);
logger.Debug("Display prefs loaded and saved in cache " + key);
outerResponse.onResponse(response);
}
@Override
public void onError(Exception exception) {
//Continue with defaults
logger.ErrorException("Unable to load display prefs ", exception);
DisplayPreferences prefs = new DisplayPreferences();
prefs.setId(key);
prefs.setSortBy("SortName");
prefs.setCustomPrefs(new HashMap<String, String>());
outerResponse.onResponse(prefs);
}
});
}
}
public int getAutoBitrate() {
return autoBitrate;
}
public void determineAutoBitrate() {
if (getApiClient() == null) return;
getApiClient().detectBitrate(new Response<Long>() {
@Override
public void onResponse(Long response) {
autoBitrate = response.intValue();
logger.Info("Auto bitrate set to: "+autoBitrate);
}
});
}
public String getDirectItemId() {
return directItemId;
}
public void setDirectItemId(String directItemId) {
this.directItemId = directItemId;
}
public String getLastDeletedItemId() {
return lastDeletedItemId;
}
public void setLastDeletedItemId(String lastDeletedItemId) {
this.lastDeletedItemId = lastDeletedItemId;
}
public VolleyHttpClient getHttpClient() {
return httpClient;
}
public void setHttpClient(VolleyHttpClient httpClient) {
this.httpClient = httpClient;
}
public BaseItemDto getLastPlayedItem() {
return lastPlayedItem;
}
public void setLastPlayedItem(BaseItemDto lastPlayedItem) {
this.lastPlayedItem = lastPlayedItem;
}
public long getLastVideoQueueChange() {
return lastVideoQueueChange;
}
public void setLastVideoQueueChange(long lastVideoQueueChange) {
this.lastVideoQueueChange = lastVideoQueueChange;
}
public boolean isPlayingIntros() {
return playingIntros;
}
public void setPlayingIntros(boolean playingIntros) {
this.playingIntros = playingIntros;
}
public ServerConfiguration getServerConfiguration() {
return serverConfiguration;
}
public int getServerBitrateLimit() { return maxRemoteBitrate > 0 ? maxRemoteBitrate : 100000000; }
public DisplayPriorityType getDisplayPriority() {
return displayPriority;
}
public void setDisplayPriority(DisplayPriorityType displayPriority) {
this.displayPriority = displayPriority;
}
public GradientDrawable getCurrentBackgroundGradient() {
return currentBackgroundGradient;
}
public void setCurrentBackground(Bitmap currentBackground) {
int[] colors = new int[2];
colors[0] = Utils.darker(Palette.from(currentBackground).generate().getMutedColor(ContextCompat.getColor(this, R.color.black_transparent)), .6f);
colors[1] = Utils.darker(colors[0], .1f);
setCurrentBackgroundGradient(colors);
}
private void setCurrentBackgroundGradient(int[] colors) {
currentBackgroundGradient = new GradientDrawable(GradientDrawable.Orientation.RIGHT_LEFT, colors);
currentBackgroundGradient.setCornerRadius(0f);
currentBackgroundGradient.setGradientCenter(.6f, .5f);
currentBackgroundGradient.setAlpha(200);
}
}

View File

@@ -1,48 +0,0 @@
package org.jellyfin.androidtv.auth.apiclient
import org.jellyfin.androidtv.auth.repository.Session
import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.jellyfin.androidtv.util.sdk.legacy
import org.jellyfin.apiclient.interaction.ApiClient
import org.jellyfin.apiclient.model.apiclient.ServerInfo
import org.jellyfin.sdk.model.DeviceInfo
import timber.log.Timber
class ApiBinder(
private val api: ApiClient,
private val authenticationStore: AuthenticationStore,
) {
fun updateSession(session: Session?, deviceInfo: DeviceInfo): Boolean {
@Suppress("TooGenericExceptionCaught")
val success = try {
updateSessionInternal(session, deviceInfo)
} catch (throwable: Throwable) {
Timber.e(throwable, "Unable to update legacy API session.")
false
}
return success
}
private fun updateSessionInternal(session: Session?, deviceInfo: DeviceInfo): Boolean {
if (session == null) return true
val server = authenticationStore.getServer(session.serverId)
if (server == null) {
Timber.e("Could not bind API because server ${session.serverId} was not found in the store.")
return false
}
api.device = deviceInfo.legacy()
api.SetAuthenticationInfo(session.accessToken, session.userId.toString())
api.EnableAutomaticNetworking(ServerInfo().apply {
id = session.serverId.toString()
name = server.name
address = server.address.removeSuffix("/")
userId = session.userId.toString()
accessToken = session.accessToken
})
return true
}
}

View File

@@ -1,10 +0,0 @@
package org.jellyfin.androidtv.auth.model
import java.util.UUID
data class AccountManagerAccount(
val id: UUID,
val server: UUID,
val name: String,
val accessToken: String? = null,
)

View File

@@ -1,6 +0,0 @@
package org.jellyfin.androidtv.auth.model
sealed class AuthenticateMethod
data class AutomaticAuthenticateMethod(val user: User) : AuthenticateMethod()
data class CredentialAuthenticateMethod(val username: String, val password: String = "") : AuthenticateMethod()
data class QuickConnectAuthenticateMethod(val secret: String) : AuthenticateMethod()

View File

@@ -1,11 +0,0 @@
package org.jellyfin.androidtv.auth.model
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
enum class AuthenticationSortBy {
@EnumDisplayOptions(name = R.string.last_use)
LAST_USE,
@EnumDisplayOptions(name = R.string.alphabetical)
ALPHABETICAL
}

View File

@@ -1,26 +0,0 @@
@file:UseSerializers(UUIDSerializer::class)
package org.jellyfin.androidtv.auth.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.Date
import java.util.UUID
/**
* Locally stored server information. New properties require default values or deserialization will fail.
*/
@Serializable
data class AuthenticationStoreServer(
val name: String,
val address: String,
val version: String? = null,
@SerialName("login_disclaimer") val loginDisclaimer: String? = null,
@SerialName("splashscreen_enabled") val splashscreenEnabled: Boolean = false,
@SerialName("setup_completed") val setupCompleted: Boolean = true,
@SerialName("last_used") val lastUsed: Long = Date().time,
@SerialName("last_refreshed") val lastRefreshed: Long = Date().time,
val users: Map<UUID, AuthenticationStoreUser> = emptyMap(),
)

View File

@@ -1,16 +0,0 @@
package org.jellyfin.androidtv.auth.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.util.Date
/**
* Locally stored user information. New properties require default values or deserialization will fail.
*/
@Serializable
data class AuthenticationStoreUser(
val name: String,
@SerialName("last_used") val lastUsed: Long = Date().time,
@SerialName("require_password") val requirePassword: Boolean = true,
@SerialName("image_tag") val imageTag: String? = null
)

View File

@@ -1,8 +0,0 @@
package org.jellyfin.androidtv.auth.model
sealed class LoginState
object AuthenticatingState : LoginState()
object RequireSignInState : LoginState()
object ServerUnavailableState : LoginState()
data class ServerVersionNotSupported(val server: Server) : LoginState()
object AuthenticatedState : LoginState()

View File

@@ -1,23 +0,0 @@
package org.jellyfin.androidtv.auth.model
sealed class QuickConnectState
/**
* State unknown untill first poll completed.
*/
object UnknownQuickConnectState : QuickConnectState()
/**
* Server does not have QuickConnect enabled.
*/
object UnavailableQuickConnectState : QuickConnectState()
/**
* Connection is pending.
*/
data class PendingQuickConnectState(val code: String) : QuickConnectState()
/**
* User connected.
*/
object ConnectedQuickConnectState : QuickConnectState()

View File

@@ -1,35 +0,0 @@
package org.jellyfin.androidtv.auth.model
import org.jellyfin.androidtv.auth.repository.ServerRepository
import org.jellyfin.sdk.model.ServerVersion
import java.util.Date
import java.util.UUID
/**
* Server model to use locally in place of ServerInfo model in ApiClient.
*/
data class Server(
var id: UUID,
var name: String,
var address: String,
val version: String? = null,
val loginDisclaimer: String? = null,
val splashscreenEnabled: Boolean = false,
val setupCompleted: Boolean = true,
var dateLastAccessed: Date = Date(0),
) {
private val serverVersion = version?.let(ServerVersion::fromString)
val versionSupported = serverVersion != null && serverVersion >= ServerRepository.minimumServerVersion
operator fun compareTo(other: ServerVersion): Int = serverVersion?.compareTo(other) ?: -1
override fun equals(other: Any?) = other is Server
&& id == other.id
&& address == other.address
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + address.hashCode()
return result
}
}

View File

@@ -1,10 +0,0 @@
package org.jellyfin.androidtv.auth.model
import org.jellyfin.sdk.discovery.RecommendedServerIssue
import org.jellyfin.sdk.model.api.PublicSystemInfo
import java.util.UUID
sealed class ServerAdditionState
data class ConnectingState(val address: String) : ServerAdditionState()
data class UnableToConnectState(val addressCandidates: Map<String, Collection<RecommendedServerIssue>>) : ServerAdditionState()
data class ConnectedState(val id: UUID, val publicInfo: PublicSystemInfo) : ServerAdditionState()

View File

@@ -1,56 +0,0 @@
package org.jellyfin.androidtv.auth.model
import java.util.UUID
/**
* User model used locally.
*/
sealed class User {
abstract val id: UUID
abstract val serverId: UUID
abstract val name: String
abstract val accessToken: String?
abstract val requirePassword: Boolean
abstract val imageTag: String?
abstract fun withToken(accessToken: String): User
override fun equals(other: Any?) = other is User
&& serverId == other.serverId
&& id == other.id
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + serverId.hashCode()
return result
}
}
/**
* Represents a user stored client side.
*/
data class PrivateUser(
override val id: UUID,
override val serverId: UUID,
override val name: String,
override val accessToken: String?,
override val requirePassword: Boolean,
override val imageTag: String?,
val lastUsed: Long,
) : User() {
override fun withToken(accessToken: String) = copy(accessToken = accessToken)
}
/**
* Represents a user stored server side. Found using the Public User endpoint.
*/
data class PublicUser(
override val id: UUID,
override val serverId: UUID,
override val name: String,
override val accessToken: String?,
override val requirePassword: Boolean,
override val imageTag: String?,
) : User() {
override fun withToken(accessToken: String) = copy(accessToken = accessToken)
}

View File

@@ -1,203 +0,0 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import org.jellyfin.androidtv.auth.model.AccountManagerAccount
import org.jellyfin.androidtv.auth.model.AuthenticateMethod
import org.jellyfin.androidtv.auth.model.AuthenticatedState
import org.jellyfin.androidtv.auth.model.AuthenticatingState
import org.jellyfin.androidtv.auth.model.AuthenticationStoreUser
import org.jellyfin.androidtv.auth.model.AutomaticAuthenticateMethod
import org.jellyfin.androidtv.auth.model.CredentialAuthenticateMethod
import org.jellyfin.androidtv.auth.model.LoginState
import org.jellyfin.androidtv.auth.model.PrivateUser
import org.jellyfin.androidtv.auth.model.QuickConnectAuthenticateMethod
import org.jellyfin.androidtv.auth.model.RequireSignInState
import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.auth.model.ServerUnavailableState
import org.jellyfin.androidtv.auth.model.ServerVersionNotSupported
import org.jellyfin.androidtv.auth.model.User
import org.jellyfin.androidtv.auth.store.AccountManagerStore
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.jellyfin.androidtv.util.ImageUtils
import org.jellyfin.androidtv.util.sdk.forUser
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.exception.TimeoutException
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.authenticateWithQuickConnect
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.api.AuthenticationResult
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.UserDto
import timber.log.Timber
import java.util.Date
/**
* Repository to manage authentication of the user in the app.
*/
interface AuthenticationRepository {
fun authenticate(server: Server, method: AuthenticateMethod): Flow<LoginState>
fun logout(user: User): Boolean
fun getUserImageUrl(server: Server, user: User): String?
}
class AuthenticationRepositoryImpl(
private val jellyfin: Jellyfin,
private val sessionRepository: SessionRepository,
private val accountManagerStore: AccountManagerStore,
private val authenticationStore: AuthenticationStore,
private val userApiClient: ApiClient,
private val authenticationPreferences: AuthenticationPreferences,
private val defaultDeviceInfo: DeviceInfo,
) : AuthenticationRepository {
override fun authenticate(server: Server, method: AuthenticateMethod): Flow<LoginState> {
// Check server version first
if (!server.versionSupported) return flowOf(ServerVersionNotSupported(server))
return when (method) {
is AutomaticAuthenticateMethod -> authenticateAutomatic(server, method.user)
is CredentialAuthenticateMethod -> authenticateCredential(server, method.username, method.password)
is QuickConnectAuthenticateMethod -> authenticateQuickConnect(server, method.secret)
}
}
private fun authenticateAutomatic(server: Server, user: User): Flow<LoginState> {
Timber.d("Authenticating user %s", user)
// Automatic logic is disabled when the always authenticate preference is enabled
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return flowOf(RequireSignInState)
val account = accountManagerStore.getAccount(user.id)
// Try login with access token
return if (account?.accessToken != null) authenticateToken(server, user.withToken(account.accessToken))
// Try login without password
else if (!user.requirePassword) authenticateCredential(server, user.name, "")
// Require login
else flowOf(RequireSignInState)
}
private fun authenticateCredential(server: Server, username: String, password: String) = flow {
val api = jellyfin.createApi(server.address, deviceInfo = defaultDeviceInfo.forUser(username))
val result = try {
val response = api.userApi.authenticateUserByName(username, password)
response.content
} catch (err: TimeoutException) {
Timber.e(err, "Failed to connect to server trying to sign in $username")
emit(ServerUnavailableState)
return@flow
} catch (err: ApiClientException) {
Timber.e(err, "Unable to sign in as $username")
emit(RequireSignInState)
return@flow
}
emitAll(authenticateAuthenticationResult(server, result))
}
private fun authenticateQuickConnect(server: Server, secret: String) = flow {
val api = jellyfin.createApi(server.address, deviceInfo = defaultDeviceInfo)
val result = try {
val response = api.userApi.authenticateWithQuickConnect(secret)
response.content
} catch (err: ApiClientException) {
Timber.e(err, "Unable to sign in with Quick Connect secret")
emit(RequireSignInState)
return@flow
}
emitAll(authenticateAuthenticationResult(server, result))
}
private fun authenticateAuthenticationResult(server: Server, result: AuthenticationResult) = flow {
val accessToken = result.accessToken ?:return@flow emit(RequireSignInState)
val userInfo = result.user ?: return@flow emit(RequireSignInState)
val user = PrivateUser(
id = userInfo.id,
serverId = server.id,
name = userInfo.name!!,
accessToken = result.accessToken,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag,
lastUsed = Date().time,
)
authenticateFinish(server, userInfo, accessToken)
val success = setActiveSession(user, server)
if (success) emit(AuthenticatedState)
else emit(RequireSignInState)
}
private fun authenticateToken(server: Server, user: User) = flow {
emit(AuthenticatingState)
val success = setActiveSession(user, server)
if (!success) {
emit(RequireSignInState)
} else try {
// Update user info
val userInfo by userApiClient.userApi.getCurrentUser()
authenticateFinish(server, userInfo, user.accessToken.orEmpty())
emit(AuthenticatedState)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to get current user data")
emit(RequireSignInState)
}
}
private suspend fun authenticateFinish(server: Server, userInfo: UserDto, accessToken: String) {
val currentUser = authenticationStore.getUser(server.id, userInfo.id)
val updatedUser = currentUser?.copy(
name = userInfo.name!!,
lastUsed = Date().time,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag
) ?: AuthenticationStoreUser(
name = userInfo.name!!,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag
)
authenticationStore.putUser(server.id, userInfo.id, updatedUser)
val accountManagerAccount = AccountManagerAccount(userInfo.id, server.id, updatedUser.name, accessToken)
accountManagerStore.putAccount(accountManagerAccount)
}
private suspend fun setActiveSession(user: User, server: Server): Boolean {
val authenticated = sessionRepository.switchCurrentSession(user.id)
if (authenticated) {
// Update last use in store
authenticationStore.getServer(server.id)?.let { storedServer ->
authenticationStore.putServer(server.id, storedServer.copy(lastUsed = Date().time))
}
authenticationStore.getUser(server.id, user.id)?.let { storedUser ->
authenticationStore.putUser(server.id, user.id, storedUser.copy(lastUsed = Date().time))
}
}
return authenticated
}
override fun logout(user: User): Boolean {
val authInfo = accountManagerStore.getAccount(user.id) ?: return false
return accountManagerStore.removeAccount(authInfo)
}
override fun getUserImageUrl(server: Server, user: User): String? = user.imageTag?.let { tag ->
jellyfin.createApi(server.address).imageApi.getUserImageUrl(
userId = user.id,
tag = tag,
imageType = ImageType.PRIMARY,
maxHeight = ImageUtils.MAX_PRIMARY_IMAGE_HEIGHT
)
}
}

View File

@@ -1,243 +0,0 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
import org.jellyfin.androidtv.auth.model.ConnectedState
import org.jellyfin.androidtv.auth.model.ConnectingState
import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.auth.model.ServerAdditionState
import org.jellyfin.androidtv.auth.model.UnableToConnectState
import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.jellyfin.androidtv.util.sdk.toServer
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.exception.InvalidContentException
import org.jellyfin.sdk.api.client.extensions.brandingApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.discovery.RecommendedServerInfo
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.model.api.BrandingOptions
import org.jellyfin.sdk.model.api.ServerDiscoveryInfo
import org.jellyfin.sdk.model.serializer.toUUID
import timber.log.Timber
import java.util.Date
import java.util.UUID
/**
* Repository to maintain servers.
*/
interface ServerRepository {
val storedServers: StateFlow<List<Server>>
val discoveredServers: StateFlow<List<Server>>
suspend fun loadStoredServers()
suspend fun loadDiscoveryServers()
fun addServer(address: String): Flow<ServerAdditionState>
suspend fun getServer(id: UUID): Server?
suspend fun updateServer(server: Server): Boolean
suspend fun deleteServer(server: UUID): Boolean
companion object {
val minimumServerVersion = Jellyfin.minimumVersion.copy(build = null)
}
}
class ServerRepositoryImpl(
private val jellyfin: Jellyfin,
private val authenticationStore: AuthenticationStore,
) : ServerRepository {
// State
private val _storedServers = MutableStateFlow(emptyList<Server>())
override val storedServers = _storedServers.asStateFlow()
private val _discoveredServers = MutableStateFlow(emptyList<Server>())
override val discoveredServers = _discoveredServers.asStateFlow()
// Loading data
override suspend fun loadStoredServers() {
authenticationStore.getServers()
.map { (id, entry) -> entry.asServer(id) }
.sortedWith(compareByDescending<Server> { it.dateLastAccessed }.thenBy { it.name })
.let { _storedServers.emit(it) }
}
override suspend fun loadDiscoveryServers() {
val servers = mutableListOf<Server>()
jellyfin.discovery
.discoverLocalServers()
.map(ServerDiscoveryInfo::toServer)
.collect { server ->
servers += server
_discoveredServers.emit(servers.toList())
}
}
// Mutating data
override fun addServer(address: String): Flow<ServerAdditionState> = flow {
Timber.d("Adding server %s", address)
emit(ConnectingState(address))
val addressCandidates = jellyfin.discovery.getAddressCandidates(address)
Timber.d("Found ${addressCandidates.size} candidates")
val goodRecommendations = mutableListOf<RecommendedServerInfo>()
val badRecommendations = mutableListOf<RecommendedServerInfo>()
val greatRecommendaton = jellyfin.discovery.getRecommendedServers(addressCandidates).firstOrNull { recommendedServer ->
when (recommendedServer.score) {
RecommendedServerInfoScore.GREAT -> true
RecommendedServerInfoScore.GOOD -> {
goodRecommendations += recommendedServer
false
}
else -> {
badRecommendations += recommendedServer
false
}
}
}
Timber.d(buildString {
append("Recommendations: ")
if (greatRecommendaton == null) append(0)
else append(1)
append(" great, ")
append(goodRecommendations.size)
append(" good, ")
append(badRecommendations.size)
append(" bad")
})
val chosenRecommendation = greatRecommendaton ?: goodRecommendations.firstOrNull()
if (chosenRecommendation != null && chosenRecommendation.systemInfo.isSuccess) {
// Get system info
val systemInfo = chosenRecommendation.systemInfo.getOrThrow()
// Get branding info
val api = jellyfin.createApi(chosenRecommendation.address)
val branding = api.getBrandingOptionsOrDefault()
val id = systemInfo.id!!.toUUID()
val server = authenticationStore.getServer(id)?.copy(
name = systemInfo.serverName ?: "Jellyfin Server",
address = chosenRecommendation.address,
version = systemInfo.version,
loginDisclaimer = branding.loginDisclaimer,
splashscreenEnabled = branding.splashscreenEnabled,
setupCompleted = systemInfo.startupWizardCompleted ?: true,
lastUsed = Date().time
) ?: AuthenticationStoreServer(
name = systemInfo.serverName ?: "Jellyfin Server",
address = chosenRecommendation.address,
version = systemInfo.version,
loginDisclaimer = branding.loginDisclaimer,
splashscreenEnabled = branding.splashscreenEnabled,
setupCompleted = systemInfo.startupWizardCompleted ?: true,
)
authenticationStore.putServer(id, server)
loadStoredServers()
emit(ConnectedState(id, systemInfo))
} else {
// No great or good recommendations, only add bad recommendations
val addressCandidatesWithIssues = (badRecommendations + goodRecommendations)
.groupBy { it.address }
.mapValues { (_, entry) -> entry.flatMap { server -> server.issues } }
emit(UnableToConnectState(addressCandidatesWithIssues))
}
}
override suspend fun getServer(id: UUID): Server? {
val server = authenticationStore.getServer(id) ?: return null
val updatedServer = try {
updateServerInternal(id, server)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to update server")
null
}
return (updatedServer ?: server).asServer(id)
}
override suspend fun updateServer(server: Server): Boolean {
// Only update existing servers
val serverInfo = authenticationStore.getServer(server.id) ?: return false
return try {
updateServerInternal(server.id, serverInfo) != null
} catch (err: ApiClientException) {
Timber.e(err, "Unable to update server")
false
}
}
private suspend fun updateServerInternal(id: UUID, server: AuthenticationStoreServer): AuthenticationStoreServer? {
val now = Date().time
// Only update every 10 minutes
if (now - server.lastRefreshed < 600000 && server.version != null) return null
val api = jellyfin.createApi(server.address)
// Get login disclaimer
val branding = api.getBrandingOptionsOrDefault()
val systemInfo by api.systemApi.getPublicSystemInfo()
val newServer = server.copy(
name = systemInfo.serverName ?: server.name,
version = systemInfo.version ?: server.version,
loginDisclaimer = branding.loginDisclaimer ?: server.loginDisclaimer,
splashscreenEnabled = branding.splashscreenEnabled,
setupCompleted = systemInfo.startupWizardCompleted ?: server.setupCompleted,
lastRefreshed = now
)
authenticationStore.putServer(id, newServer)
return newServer
}
override suspend fun deleteServer(server: UUID): Boolean {
val success = authenticationStore.removeServer(server)
if (success) loadStoredServers()
return success
}
// Helper functions
private fun AuthenticationStoreServer.asServer(id: UUID) = Server(
id = id,
name = name,
address = address,
version = version,
loginDisclaimer = loginDisclaimer,
splashscreenEnabled = splashscreenEnabled,
setupCompleted = setupCompleted,
dateLastAccessed = Date(lastUsed),
)
/**
* Try to retrieve the branding options. If the response JSON is invalid it will return a default value.
* This makes sure we can still work with older Jellyfin versions.
*/
private suspend fun ApiClient.getBrandingOptionsOrDefault() = try {
brandingApi.getBrandingOptions().content
} catch (exception: InvalidContentException) {
Timber.w(exception, "Invalid branding options response, using default value")
BrandingOptions(
loginDisclaimer = null,
customCss = null,
splashscreenEnabled = false,
)
}
}

View File

@@ -1,71 +0,0 @@
package org.jellyfin.androidtv.auth.repository
import org.jellyfin.androidtv.auth.model.PrivateUser
import org.jellyfin.androidtv.auth.model.PublicUser
import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.auth.store.AccountManagerStore
import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.jellyfin.androidtv.util.sdk.toPublicUser
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.UserDto
import timber.log.Timber
/**
* Repository to maintain users for servers.
* Authentication is done using the [AuthenticationRepository].
*/
interface ServerUserRepository {
//server
fun getStoredServerUsers(server: Server): List<PrivateUser>
suspend fun getPublicServerUsers(server: Server): List<PublicUser>
fun deleteStoredUser(user: PrivateUser)
}
class ServerUserRepositoryImpl(
private val jellyfin: Jellyfin,
private val authenticationStore: AuthenticationStore,
private val accountManagerStore: AccountManagerStore,
) : ServerUserRepository {
override fun getStoredServerUsers(server: Server) = authenticationStore.getUsers(server.id)
?.mapNotNull { (userId, userInfo) ->
val authInfo = accountManagerStore.getAccount(userId)
PrivateUser(
id = authInfo?.id ?: userId,
serverId = authInfo?.server ?: server.id,
name = userInfo.name,
accessToken = authInfo?.accessToken,
requirePassword = userInfo.requirePassword,
imageTag = userInfo.imageTag,
lastUsed = userInfo.lastUsed,
)
}
?.sortedWith(compareByDescending<PrivateUser> { it.lastUsed }.thenBy { it.name })
.orEmpty()
override suspend fun getPublicServerUsers(server: Server): List<PublicUser> {
// Create a fresh API because the shared one might be authenticated for a different server
val api = jellyfin.createApi(server.address)
return try {
val users by api.userApi.getPublicUsers()
users.mapNotNull(UserDto::toPublicUser)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to retrieve public users")
emptyList()
}
}
override fun deleteStoredUser(user: PrivateUser) {
// Remove user info from store
authenticationStore.removeUser(user.serverId, user.id)
// Remove authentication info from system account manager
accountManagerStore.getAccount(user.id)?.let { accountManagerAccount ->
accountManagerStore.removeAccount(accountManagerAccount)
}
}
}

View File

@@ -1,190 +0,0 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.jellyfin.androidtv.auth.apiclient.ApiBinder
import org.jellyfin.androidtv.auth.store.AccountManagerStore
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.jellyfin.androidtv.preference.PreferencesRepository
import org.jellyfin.androidtv.preference.TelemetryPreferences
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior.DISABLED
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior.LAST_USER
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior.SPECIFIC_USER
import org.jellyfin.androidtv.util.sdk.forUser
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.clientLogApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
data class Session(
val userId: UUID,
val serverId: UUID,
val accessToken: String,
)
enum class SessionRepositoryState {
READY,
RESTORING_SESSION,
SWITCHING_SESSION,
}
interface SessionRepository {
val currentSession: StateFlow<Session?>
val state: StateFlow<SessionRepositoryState>
suspend fun restoreSession()
suspend fun switchCurrentSession(userId: UUID): Boolean
fun destroyCurrentSession()
}
class SessionRepositoryImpl(
private val authenticationPreferences: AuthenticationPreferences,
private val accountManagerStore: AccountManagerStore,
private val apiBinder: ApiBinder,
private val authenticationStore: AuthenticationStore,
private val userApiClient: ApiClient,
private val preferencesRepository: PreferencesRepository,
private val defaultDeviceInfo: DeviceInfo,
private val userRepository: UserRepository,
private val serverRepository: ServerRepository,
private val telemetryPreferences: TelemetryPreferences,
) : SessionRepository {
private val currentSessionMutex = Mutex()
private val _currentSession = MutableStateFlow<Session?>(null)
override val currentSession = _currentSession.asStateFlow()
private val _state = MutableStateFlow(SessionRepositoryState.READY)
override val state = _state.asStateFlow()
override suspend fun restoreSession(): Unit = currentSessionMutex.withLock {
Timber.d("Restoring session")
_state.value = SessionRepositoryState.RESTORING_SESSION
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return destroyCurrentSession()
val behavior = authenticationPreferences[AuthenticationPreferences.autoLoginUserBehavior]
val userId = authenticationPreferences[AuthenticationPreferences.autoLoginUserId].toUUIDOrNull()
when (behavior) {
DISABLED -> destroyCurrentSession()
LAST_USER -> setCurrentSession(createLastUserSession())
SPECIFIC_USER -> setCurrentSession(createUserSession(userId))
}
_state.value = SessionRepositoryState.READY
}
override suspend fun switchCurrentSession(userId: UUID): Boolean {
// No change in user - don't switch
if (currentSession.value?.userId == userId) return false
_state.value = SessionRepositoryState.SWITCHING_SESSION
Timber.d("Switching current session to user $userId")
val session = createUserSession(userId)
if (session == null) {
Timber.d("Could not switch to non-existing session for user $userId")
return false
}
val switched = setCurrentSession(session)
_state.value = SessionRepositoryState.READY
return switched
}
override fun destroyCurrentSession() {
Timber.d("Destroying current session")
userRepository.updateCurrentUser(null)
_currentSession.value = null
userApiClient.applySession(null)
apiBinder.updateSession(null, userApiClient.deviceInfo)
_state.value = SessionRepositoryState.READY
}
private suspend fun setCurrentSession(session: Session?): Boolean {
if (session != null) {
// No change in session - don't switch
if (currentSession.value?.userId == session.userId) return true
// Update last active user
authenticationPreferences[AuthenticationPreferences.lastUserId] = session.userId.toString()
// Check if server version is supported
val server = serverRepository.getServer(session.serverId)
if (server == null || !server.versionSupported) return false
}
// Update session after binding the apiclient settings
val deviceInfo = session?.let { defaultDeviceInfo.forUser(it.userId) } ?: defaultDeviceInfo
val success = apiBinder.updateSession(session, deviceInfo)
Timber.d("Updating current session. userId=${session?.userId} apiBindingSuccess=${success}")
if (success) {
userApiClient.applySession(session, deviceInfo)
if (session != null) {
// Update crash reporting URL
val crashReportUrl = userApiClient.clientLogApi.logFileUrl(includeCredentials = false)
telemetryPreferences[TelemetryPreferences.crashReportUrl] = crashReportUrl
telemetryPreferences[TelemetryPreferences.crashReportToken] = session.accessToken
try {
val user by userApiClient.userApi.getCurrentUser()
userRepository.updateCurrentUser(user)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to authenticate: bad response when getting user info")
destroyCurrentSession()
return false
}
} else {
userRepository.updateCurrentUser(null)
}
preferencesRepository.onSessionChanged()
_currentSession.value = session
} else destroyCurrentSession()
return success
}
private fun createLastUserSession(): Session? {
val lastUserId = authenticationPreferences[AuthenticationPreferences.lastUserId].toUUIDOrNull()
return createUserSession(lastUserId)
}
private fun createUserSession(userId: UUID?): Session? {
if (userId == null) return null
val account = accountManagerStore.getAccount(userId)
if (account?.accessToken == null) return null
return Session(
userId = account.id,
serverId = account.server,
accessToken = account.accessToken
)
}
private fun ApiClient.applySession(session: Session?, newDeviceInfo: DeviceInfo = defaultDeviceInfo) {
deviceInfo = newDeviceInfo
if (session == null) {
baseUrl = null
accessToken = null
userId = null
} else {
val server = authenticationStore.getServer(session.serverId)
?: return applySession(null)
baseUrl = server.address
accessToken = session.accessToken
userId = session.userId
}
}
}

View File

@@ -1,22 +0,0 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.jellyfin.sdk.model.api.UserDto
/**
* Repository to get the current authenticated user.
*/
interface UserRepository {
val currentUser: StateFlow<UserDto?>
fun updateCurrentUser(user: UserDto?)
}
class UserRepositoryImpl : UserRepository {
override val currentUser = MutableStateFlow<UserDto?>(null)
override fun updateCurrentUser(user: UserDto?) {
if (currentUser.value !== user) currentUser.value = user
}
}

View File

@@ -1,79 +0,0 @@
package org.jellyfin.androidtv.auth.service
import android.accounts.AbstractAccountAuthenticator
import android.accounts.Account
import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager
import android.app.Service
import android.content.Intent
import android.os.Bundle
import android.os.IBinder
import androidx.core.os.bundleOf
import org.jellyfin.androidtv.ui.preference.PreferencesActivity
class AuthenticatorService : Service() {
private val authenticator by lazy {
Authenticator(this)
}
override fun onBind(intent: Intent): IBinder? = authenticator.iBinder
private inner class Authenticator(
private val service: AuthenticatorService
) : AbstractAccountAuthenticator(service) {
private val unsupportedOperationBundle = bundleOf(
AccountManager.KEY_ERROR_CODE to AccountManager.ERROR_CODE_REMOTE_EXCEPTION,
AccountManager.KEY_ERROR_MESSAGE to "Unsupported operation"
)
override fun addAccount(
response: AccountAuthenticatorResponse,
accountType: String,
authTokenType: String?,
requiredFeatures: Array<String>?,
options: Bundle,
): Bundle = unsupportedOperationBundle
override fun confirmCredentials(
response: AccountAuthenticatorResponse,
account: Account,
options: Bundle?,
): Bundle = unsupportedOperationBundle
override fun editProperties(
response: AccountAuthenticatorResponse,
accountType: String,
): Bundle = bundleOf(
AccountManager.KEY_INTENT to Intent(service, PreferencesActivity::class.java)
)
override fun getAccountRemovalAllowed(
response: AccountAuthenticatorResponse,
account: Account,
): Bundle = bundleOf(
AccountManager.KEY_BOOLEAN_RESULT to true
)
override fun getAuthToken(
response: AccountAuthenticatorResponse,
account: Account,
authTokenType: String,
options: Bundle,
): Bundle = unsupportedOperationBundle
override fun getAuthTokenLabel(authTokenType: String): String? = null
override fun hasFeatures(
response: AccountAuthenticatorResponse,
account: Account,
features: Array<String>,
): Bundle = unsupportedOperationBundle
override fun updateCredentials(
response: AccountAuthenticatorResponse,
account: Account,
authTokenType: String?,
options: Bundle?,
): Bundle = unsupportedOperationBundle
}
}

View File

@@ -1,82 +0,0 @@
package org.jellyfin.androidtv.auth.store
import android.accounts.Account
import android.accounts.AccountManager
import android.os.Bundle
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.auth.model.AccountManagerAccount
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class AccountManagerStore(
private val accountManager: AccountManager
) {
companion object {
const val ACCOUNT_TYPE = BuildConfig.APPLICATION_ID
const val ACCOUNT_DATA_ID = "$ACCOUNT_TYPE.id"
const val ACCOUNT_DATA_SERVER = "$ACCOUNT_TYPE.server"
const val ACCOUNT_DATA_NAME = "$ACCOUNT_TYPE.name"
const val ACCOUNT_ACCESS_TOKEN_TYPE = "$ACCOUNT_TYPE.access_token"
}
private fun getAccountData(account: Account): AccountManagerAccount = AccountManagerAccount(
id = accountManager.getUserData(account, ACCOUNT_DATA_ID).toUUID(),
server = accountManager.getUserData(account, ACCOUNT_DATA_SERVER).toUUID(),
name = accountManager.getUserData(account, ACCOUNT_DATA_NAME),
accessToken = accountManager.peekAuthToken(account, ACCOUNT_ACCESS_TOKEN_TYPE)
)
suspend fun putAccount(accountManagerAccount: AccountManagerAccount) {
var androidAccount = accountManager.getAccountsByType(ACCOUNT_TYPE)
.firstOrNull { accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == accountManagerAccount.id }
// Update credentials
if (androidAccount == null) {
androidAccount = Account(accountManagerAccount.name, ACCOUNT_TYPE)
accountManager.addAccountExplicitly(
androidAccount,
"", // Leave password empty
Bundle()
)
accountManager.setUserData(androidAccount, ACCOUNT_DATA_ID, accountManagerAccount.id.toString())
}
// Update name
if (androidAccount.name != accountManagerAccount.name) {
androidAccount = suspendCoroutine { continuation ->
accountManager.renameAccount(androidAccount, accountManagerAccount.name, { continuation.resume(it.result) }, null)
}
}
accountManager.setUserData(androidAccount, ACCOUNT_DATA_NAME, accountManagerAccount.name)
accountManager.setUserData(androidAccount, ACCOUNT_DATA_SERVER, accountManagerAccount.server.toString())
accountManager.setAuthToken(androidAccount, ACCOUNT_ACCESS_TOKEN_TYPE, accountManagerAccount.accessToken)
}
fun removeAccount(accountManagerAccount: AccountManagerAccount): Boolean {
val androidAccount = accountManager.getAccountsByType(ACCOUNT_TYPE)
.firstOrNull { accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == accountManagerAccount.id }
?: return false
// Remove current account info
@Suppress("DEPRECATION")
return if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.LOLLIPOP_MR1) {
accountManager.removeAccount(androidAccount, null, null)
true
} else accountManager.removeAccountExplicitly(androidAccount)
}
fun getAccounts() = accountManager.getAccountsByType(ACCOUNT_TYPE).map(::getAccountData)
fun getAccountsByServer(server: UUID) = accountManager.getAccountsByType(ACCOUNT_TYPE).filter { account ->
accountManager.getUserData(account, ACCOUNT_DATA_SERVER)?.toUUIDOrNull() == server
}.map(::getAccountData)
fun getAccount(id: UUID) = accountManager.getAccountsByType(ACCOUNT_TYPE).firstOrNull { account ->
accountManager.getUserData(account, ACCOUNT_DATA_ID)?.toUUIDOrNull() == id
}?.let(::getAccountData)
}

View File

@@ -1,25 +0,0 @@
package org.jellyfin.androidtv.auth.store
import android.content.Context
import org.jellyfin.androidtv.auth.model.AuthenticationSortBy
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior
import org.jellyfin.preference.booleanPreference
import org.jellyfin.preference.enumPreference
import org.jellyfin.preference.store.SharedPreferenceStore
import org.jellyfin.preference.stringPreference
class AuthenticationPreferences(context: Context) : SharedPreferenceStore(
sharedPreferences = context.getSharedPreferences("authentication", Context.MODE_PRIVATE)
) {
companion object {
// Preferences
val autoLoginUserBehavior = enumPreference("auto_login_user_behavior", UserSelectBehavior.LAST_USER)
val autoLoginUserId = stringPreference("auto_login_user_id", "")
val sortBy = enumPreference("sort_by", AuthenticationSortBy.LAST_USE)
val alwaysAuthenticate = booleanPreference("always_authenticate", false)
// Persistent state
val lastUserId = stringPreference("last_user_id", "")
}
}

View File

@@ -1,120 +0,0 @@
package org.jellyfin.androidtv.auth.store
import android.content.Context
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.contextual
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
import org.jellyfin.androidtv.auth.model.AuthenticationStoreUser
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import timber.log.Timber
import java.util.UUID
/**
* Storage for authentication related entities. Stores servers with users inside. Should be used in
* combination with the [AccountManagerStore] to store access tokens and integrate with the
* operating system.
*
* The data is stored in a JSON file located in the applications data directory.
*/
class AuthenticationStore(
private val context: Context
) {
private val storePath
get() = context.filesDir.resolve("authentication_store.json")
private val json = Json {
encodeDefaults = true
serializersModule = SerializersModule {
contextual(UUIDSerializer())
}
ignoreUnknownKeys = true
}
private val store by lazy {
load().toMutableMap()
}
private fun load(): Map<UUID, AuthenticationStoreServer> {
// No store found
if (!storePath.exists()) return emptyMap()
// Parse JSON document
val root = try {
json.parseToJsonElement(storePath.readText()).jsonObject
} catch (e: SerializationException) {
Timber.e(e, "Unable to read JSON")
JsonObject(emptyMap())
}
// Check for version
return when (root["version"]?.jsonPrimitive?.intOrNull) {
1 -> json.decodeFromJsonElement<Map<UUID, AuthenticationStoreServer>>(root["servers"]!!)
null -> {
Timber.e("Authentication Store is corrupt!")
emptyMap()
}
else -> {
Timber.e("Authentication Store is using an unknown version!")
emptyMap()
}
}
}
private fun save(): Boolean {
val root = JsonObject(mapOf(
"version" to JsonPrimitive(1),
"servers" to json.encodeToJsonElement(store)
))
storePath.writeText(json.encodeToString(root))
return true
}
fun getServers(): Map<UUID, AuthenticationStoreServer> = store
fun getUsers(server: UUID): Map<UUID, AuthenticationStoreUser>? = getServer(server)?.users
fun getServer(serverId: UUID) = store[serverId]
fun getUser(serverId: UUID, userId: UUID) = getUsers(serverId)?.get(userId)
fun putServer(id: UUID, server: AuthenticationStoreServer): Boolean {
store[id] = server
return save()
}
fun putUser(server: UUID, userId: UUID, userInfo: AuthenticationStoreUser): Boolean {
val serverInfo = store[server] ?: return false
store[server] = serverInfo.copy(users = serverInfo.users.toMutableMap().apply { put(userId, userInfo) })
return save()
}
/**
* Removes the server and stored users from the credential store.
*/
fun removeServer(server: UUID): Boolean {
store.remove(server)
return save()
}
fun removeUser(server: UUID, user: UUID): Boolean {
val serverInfo = store[server] ?: return false
store[server] = serverInfo.copy(users = serverInfo.users.toMutableMap().apply { remove(user) })
return save()
}
}

View File

@@ -1,31 +0,0 @@
package org.jellyfin.androidtv.auth.ui
import android.content.Intent
import androidx.fragment.app.FragmentActivity
import org.jellyfin.androidtv.auth.repository.SessionRepository
import org.jellyfin.androidtv.auth.repository.UserRepository
import org.jellyfin.androidtv.ui.startup.StartupActivity
import org.koin.android.ext.android.inject
import timber.log.Timber
/**
* Extension function to check authentication. Should be called in [FragmentActivity.onCreate] and
* [FragmentActivity.onResume]. It validates the current session and opens the authentication screen
* when no session is found.
*
* @return whether to proceed creating the activity or not. When `false` is returned the
* [FragmentActivity.finish] function is automatically called.
*/
fun FragmentActivity.validateAuthentication(): Boolean {
val sessionRepository by inject<SessionRepository>()
val userRepository by inject<UserRepository>()
if (sessionRepository.currentSession.value == null || userRepository.currentUser.value == null) {
Timber.w("Activity ${this::class.qualifiedName} started without a session, bouncing to StartupActivity")
startActivity(Intent(this, StartupActivity::class.java))
finish()
return false
}
return true
}

View File

@@ -0,0 +1,192 @@
package org.jellyfin.androidtv.base;
import android.graphics.Rect;
import android.os.Bundle;
import android.os.Handler;
import android.preference.PreferenceManager;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.TvApp;
import org.jellyfin.androidtv.util.Utils;
import androidx.core.app.ActivityCompat;
import androidx.fragment.app.FragmentActivity;
public abstract class BaseActivity extends FragmentActivity implements ActivityCompat.OnRequestPermissionsResultCallback {
private TvApp app = TvApp.getApplication();
private long timeoutInterval = 3600000;
private Handler handler = new Handler();
private Runnable loop;
private IKeyListener keyListener;
private IMessageListener messageListener;
private View messageUi;
private TextView messageTitle;
private TextView messageMessage;
private ImageView messageIcon;
private int messageTimeout = 6000;
private int farRight;
private int msgPos;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
timeoutInterval = Long.parseLong(PreferenceManager.getDefaultSharedPreferences(app).getString("pref_auto_logoff_timeout", "3600000"));
startAutoLogoffLoop();
TvApp.getApplication().setCurrentActivity(this);
//Add message UI - delay to ensure on top of other views
handler.postDelayed(new Runnable() {
@Override
public void run() {
if (!isFinishing() && app.getCurrentActivity() != null) {
FrameLayout root = findViewById(android.R.id.content);
messageUi = View.inflate(app.getCurrentActivity(), R.layout.message, null);
messageTitle = messageUi.findViewById(R.id.msgTitle);
messageMessage = messageUi.findViewById(R.id.message);
messageIcon = messageUi.findViewById(R.id.msgIcon);
messageUi.setAlpha(0);
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.RIGHT | Gravity.BOTTOM);
params.bottomMargin = Utils.convertDpToPixel(TvApp.getApplication().getCurrentActivity(), 50);
Rect windowSize = new Rect();
getWindow().getDecorView().getWindowVisibleDisplayFrame(windowSize);
farRight = windowSize.right;
msgPos = farRight - Utils.convertDpToPixel(TvApp.getApplication().getCurrentActivity(), 500);
messageUi.setLeft(farRight);
root.addView(messageUi, params);
}
}
}, 500);
}
public void showMessage(String title, String msg) {
showMessage(title, msg, messageTimeout, null, null);
}
public void showMessage(String title, String msg, int timeout) {
showMessage(title, msg, timeout, null, null);
}
public void showMessage(String title, String msg, int timeout, Integer icon, Integer background) {
if (messageUi != null && !isFinishing()) {
messageTitle.setText(title);
messageMessage.setText(msg);
if (icon != null) {
messageIcon.setImageResource(icon);
}
if (background != null) {
messageUi.setBackgroundResource(background);
}
messageUi.animate().x(msgPos).alpha(1).setDuration(300);
handler.postDelayed(new Runnable() {
@Override
public void run() {
if (messageUi != null && !isFinishing()) {
messageUi.animate().alpha(0).x(farRight).setDuration(300);
}
}
}, timeout);
}
}
//Banish task bars and navigation controls on non-TV devices
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus && android.os.Build.VERSION.SDK_INT >= 19) {
getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);}
}
@Override
protected void onResume() {
super.onResume();
TvApp.getApplication().setCurrentActivity(this);
}
@Override
protected void onPause() {
super.onPause();
TvApp.getApplication().setCurrentActivity(null);
}
@Override
protected void onDestroy() {
if (handler != null && loop != null) {
handler.removeCallbacks(loop);
}
super.onDestroy();
}
@Override
public void onUserInteraction() {
super.onUserInteraction();
app.setLastUserInteraction(System.currentTimeMillis());
}
private void startAutoLogoffLoop() {
if (loop != null) {
return;
}
loop = new Runnable() {
@Override
public void run() {
if (app != null && System.currentTimeMillis() > app.getLastUserInteraction() + timeoutInterval) {
app.getLogger().Info("Logging off due to inactivity %d", app.getLastUserInteraction());
Utils.showToast(app, "Jellyfin Logging off due to inactivity...");
if (app.getPlaybackController() != null && app.getPlaybackController().isPaused()) {
app.getLogger().Info("Playback was paused, stopping gracefully...");
app.getPlaybackController().stop();
}
handler.removeCallbacks(this);
finish();
} else {
handler.postDelayed(this, 60000);
}
}
};
handler.postDelayed(loop, 60000);
}
public void registerKeyListener(IKeyListener listener) {
keyListener = listener;
}
public void registerMessageListener(IMessageListener listener) {
messageListener = listener;
}
public void sendMessage(CustomMessage message) {
if (messageListener != null) {
messageListener.onMessageReceived(message);
}
}
@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
return keyListener != null ? keyListener.onKeyUp(keyCode, event) || super.onKeyUp(keyCode, event) : super.onKeyUp(keyCode, event);
}
@Override
public boolean onSearchRequested() {
TvApp.getApplication().showSearch(this, false);
return true;
}
}

View File

@@ -0,0 +1,9 @@
package org.jellyfin.androidtv.base;
/**
* Created by Eric on 4/20/2015.
*/
public enum CustomMessage {
RefreshCurrentItem,
RemoveCurrentItem, ActionComplete, RefreshRows
}

View File

@@ -0,0 +1,10 @@
package org.jellyfin.androidtv.base;
import android.view.KeyEvent;
/**
* Created by Eric on 4/16/2015.
*/
public interface IKeyListener {
public boolean onKeyUp(int key, KeyEvent event);
}

View File

@@ -0,0 +1,8 @@
package org.jellyfin.androidtv.base;
/**
* Created by Eric on 4/20/2015.
*/
public interface IMessageListener {
public void onMessageReceived(CustomMessage message);
}

View File

@@ -0,0 +1,125 @@
package org.jellyfin.androidtv.browsing;
import android.content.Intent;
import android.os.Bundle;
import androidx.leanback.widget.ArrayObjectAdapter;
import androidx.leanback.widget.HeaderItem;
import androidx.leanback.widget.ListRow;
import androidx.leanback.widget.OnItemViewClickedListener;
import androidx.leanback.widget.Presenter;
import androidx.leanback.widget.Row;
import androidx.leanback.widget.RowPresenter;
import android.widget.Toast;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.TvApp;
import org.jellyfin.androidtv.presentation.GridButtonPresenter;
import org.jellyfin.androidtv.ui.GridButton;
import org.jellyfin.apiclient.model.dto.BaseItemDto;
/**
* Created by Eric on 12/4/2014.
*/
public class BrowseFolderFragment extends StdBrowseFragment {
protected static final int BY_LETTER = 0;
protected static final int GENRES = 1;
protected static final int YEARS = 2;
protected static final int PERSONS = 3;
protected static final int SUGGESTED = 4;
protected BaseItemDto mFolder;
protected String itemTypeString;
protected boolean showViews = true;
@Override
public void onActivityCreated(Bundle savedInstanceState) {
mFolder = TvApp.getApplication().getSerializer().DeserializeFromString(getActivity().getIntent().getStringExtra("Folder"),BaseItemDto.class);
if (MainTitle == null) MainTitle = mFolder.getName();
ShowBadge = false;
if (mFolder.getCollectionType() != null) {
switch (mFolder.getCollectionType()) {
case "movies":
itemTypeString = "Movie";
break;
case "tvshows":
itemTypeString = "Series";
break;
case "folders":
showViews = false;
break;
default:
showViews = false;
}
} else {
showViews = false;
}
super.onActivityCreated(savedInstanceState);
}
@Override
protected void addAdditionalRows(ArrayObjectAdapter rowAdapter) {
super.addAdditionalRows(rowAdapter);
if (showViews) {
HeaderItem gridHeader = new HeaderItem(rowAdapter.size(), mApplication.getString(R.string.lbl_views));
GridButtonPresenter mGridPresenter = new GridButtonPresenter();
ArrayObjectAdapter gridRowAdapter = new ArrayObjectAdapter(mGridPresenter);
gridRowAdapter.add(new GridButton(BY_LETTER, mApplication.getString(R.string.lbl_by_letter), R.drawable.tile_letters));
if (itemTypeString != null && itemTypeString.equals("Movie"))
gridRowAdapter.add(new GridButton(SUGGESTED, mApplication.getString(R.string.lbl_suggested), R.drawable.tile_suggestions));
gridRowAdapter.add(new GridButton(GENRES, mApplication.getString(R.string.lbl_genres), R.drawable.tile_genres));
gridRowAdapter.add(new GridButton(PERSONS, mApplication.getString(R.string.lbl_performers), R.drawable.tile_actors));
rowAdapter.add(new ListRow(gridHeader, gridRowAdapter));
}
}
@Override
protected void setupEventListeners() {
super.setupEventListeners();
if (showViews) mClickedListener.registerListener(new ItemViewClickedListener());
}
private final class ItemViewClickedListener implements OnItemViewClickedListener {
@Override
public void onItemClicked(Presenter.ViewHolder itemViewHolder, Object item,
RowPresenter.ViewHolder rowViewHolder, Row row) {
if (item instanceof GridButton) {
switch (((GridButton) item).getId()) {
case BY_LETTER:
Intent intent = new Intent(getActivity(), ByLetterActivity.class);
intent.putExtra("Folder", TvApp.getApplication().getSerializer().SerializeToString(mFolder));
intent.putExtra("IncludeType", itemTypeString);
getActivity().startActivity(intent);
break;
case GENRES:
Intent genreIntent = new Intent(getActivity(), ByGenreActivity.class);
genreIntent.putExtra("Folder", TvApp.getApplication().getSerializer().SerializeToString(mFolder));
genreIntent.putExtra("IncludeType", itemTypeString);
getActivity().startActivity(genreIntent);
break;
case SUGGESTED:
Intent suggIntent = new Intent(getActivity(), SuggestedMoviesActivity.class);
suggIntent.putExtra("Folder", TvApp.getApplication().getSerializer().SerializeToString(mFolder));
suggIntent.putExtra("IncludeType", itemTypeString);
getActivity().startActivity(suggIntent);
break;
case PERSONS:
Intent personIntent = new Intent(getActivity(), BrowsePersonsActivity.class);
personIntent.putExtra("Folder", TvApp.getApplication().getSerializer().SerializeToString(mFolder));
personIntent.putExtra("IncludeType", itemTypeString);
getActivity().startActivity(personIntent);
break;
default:
Toast.makeText(getActivity(), item.toString() + mApplication.getString(R.string.msg_not_implemented), Toast.LENGTH_SHORT).show();
break;
}
}
}
}
}

View File

@@ -0,0 +1,74 @@
package org.jellyfin.androidtv.browsing;
import android.os.Bundle;
import org.jellyfin.androidtv.TvApp;
import org.jellyfin.androidtv.model.ChangeTriggerType;
import org.jellyfin.androidtv.querying.StdItemQuery;
import org.jellyfin.apiclient.model.dto.BaseItemType;
import org.jellyfin.apiclient.model.querying.ArtistsQuery;
import org.jellyfin.apiclient.model.querying.ItemFields;
/**
* Created by Eric on 8/16/2015.
*/
public class BrowseGridFragment extends StdGridFragment {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
}
@Override
protected void setupQueries(IGridLoader gridLoader) {
StdItemQuery query = new StdItemQuery(new ItemFields[] {ItemFields.PrimaryImageAspectRatio});
query.setParentId(mParentId);
if (mFolder.getBaseItemType() == BaseItemType.UserView || mFolder.getBaseItemType() == BaseItemType.CollectionFolder) {
String type = mFolder.getCollectionType() != null ? mFolder.getCollectionType().toLowerCase() : "";
switch (type) {
case "movies":
query.setIncludeItemTypes(new String[]{"Movie"});
query.setRecursive(true);
break;
case "tvshows":
query.setIncludeItemTypes(new String[]{"Series"});
query.setRecursive(true);
break;
case "boxsets":
query.setIncludeItemTypes(new String[]{"BoxSet"});
query.setParentId(null);
query.setRecursive(true);
break;
case "music":
mAllowViewSelection = false;
//Special queries needed for album artists
String includeType = getActivity().getIntent().getStringExtra("IncludeType");
if ("AlbumArtist".equals(includeType)) {
ArtistsQuery albumArtists = new ArtistsQuery();
albumArtists.setUserId(TvApp.getApplication().getCurrentUser().getId());
albumArtists.setFields(new ItemFields[]{ItemFields.PrimaryImageAspectRatio, ItemFields.ItemCounts});
albumArtists.setParentId(mParentId);
mRowDef = new BrowseRowDef("", albumArtists, 150, new ChangeTriggerType[] {});
gridLoader.loadGrid(mRowDef);
return;
}
query.setIncludeItemTypes(new String[]{includeType != null ? includeType : "MusicAlbum"});
query.setRecursive(true);
break;
}
}
mRowDef = new BrowseRowDef("", query, 150, false, true);
gridLoader.loadGrid(mRowDef);
}
}

View File

@@ -0,0 +1,15 @@
package org.jellyfin.androidtv.browsing;
import android.os.Bundle;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.base.BaseActivity;
public class BrowsePersonsActivity extends BaseActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_browse_persons);
}
}

View File

@@ -0,0 +1,49 @@
package org.jellyfin.androidtv.browsing;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.TvApp;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.apiclient.model.querying.ItemSortBy;
import org.jellyfin.apiclient.model.querying.PersonsQuery;
public class BrowsePersonsFragment extends CustomViewFragment {
private static String letters = TvApp.getApplication().getResources().getString(R.string.byletter_letters);
@Override
protected void setupQueries(IRowLoader rowLoader) {
if (Utils.getSafeValue(mFolder.getChildCount(), 0) > 0) {
//First add a '#' item
PersonsQuery numbers = new PersonsQuery();
numbers.setParentId(mFolder.getId());
numbers.setSortBy(new String[]{ItemSortBy.SortName});
if (includeType != null) numbers.setIncludeItemTypes(new String[]{includeType});
numbers.setNameLessThan("A");
numbers.setRecursive(true);
mRows.add(new BrowseRowDef("#", numbers, 25));
//Then all the defined letters
for (Character letter : letters.toCharArray()) {
PersonsQuery letterQuery = new PersonsQuery();
letterQuery.setParentId(mFolder.getId());
letterQuery.setSortBy(new String[]{ItemSortBy.SortName});
if (includeType != null) letterQuery.setIncludeItemTypes(new String[]{includeType});
letterQuery.setNameStartsWith(letter.toString());
letterQuery.setRecursive(true);
mRows.add(new BrowseRowDef(letter.toString(), letterQuery, 25));
}
if (mRows.size() < 2) setHeadersState(HEADERS_DISABLED);
rowLoader.loadRows(mRows);
}
else {
setHeadersState(HEADERS_DISABLED);
}
}
}

View File

@@ -0,0 +1,15 @@
package org.jellyfin.androidtv.browsing;
import android.os.Bundle;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.base.BaseActivity;
public class BrowseRecordingsActivity extends BaseActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_recordings);
}
}

View File

@@ -1,120 +1,102 @@
package org.jellyfin.androidtv.ui.browsing;
package org.jellyfin.androidtv.browsing;
import android.os.Bundle;
import android.os.Handler;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.leanback.widget.ArrayObjectAdapter;
import androidx.leanback.widget.HeaderItem;
import androidx.leanback.widget.ListRow;
import androidx.leanback.widget.Row;
import androidx.lifecycle.Lifecycle;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.auth.repository.UserRepository;
import org.jellyfin.androidtv.TvApp;
import org.jellyfin.androidtv.itemhandling.ItemRowAdapter;
import org.jellyfin.androidtv.model.DisplayPriorityType;
import org.jellyfin.androidtv.presentation.GridButtonPresenter;
import org.jellyfin.androidtv.ui.GridButton;
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapter;
import org.jellyfin.androidtv.ui.presentation.GridButtonPresenter;
import org.jellyfin.androidtv.ui.presentation.MutableObjectAdapter;
import org.jellyfin.androidtv.util.TimeUtils;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.androidtv.util.apiclient.LifecycleAwareResponse;
import org.jellyfin.apiclient.interaction.ApiClient;
import java.util.ArrayList;
import java.util.List;
import org.jellyfin.apiclient.interaction.Response;
import org.jellyfin.apiclient.model.dto.BaseItemDto;
import org.jellyfin.apiclient.model.dto.BaseItemType;
import org.jellyfin.apiclient.model.entities.LocationType;
import org.jellyfin.apiclient.model.livetv.RecordingGroupQuery;
import org.jellyfin.apiclient.model.livetv.RecordingQuery;
import org.jellyfin.apiclient.model.livetv.TimerInfoDto;
import org.jellyfin.apiclient.model.livetv.TimerQuery;
import org.jellyfin.apiclient.model.querying.ItemFields;
import org.jellyfin.apiclient.model.results.TimerInfoDtoResult;
import org.koin.java.KoinJavaComponent;
import java.util.ArrayList;
import java.util.List;
import timber.log.Timber;
/**
* Created by Eric on 9/3/2015.
*/
public class BrowseRecordingsFragment extends EnhancedBrowseFragment {
@Override
public void onResume() {
super.onResume();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
protected void setupQueries(final IRowLoader rowLoader) {
mTitle.setText(getString(R.string.lbl_loading_elipses));
}
@Override
protected void setupQueries(final RowLoader rowLoader) {
showViews = true;
mTitle.setText(TvApp.getApplication().getResources().getString(R.string.lbl_loading_elipses));
//Latest Recordings
RecordingQuery recordings = new RecordingQuery();
recordings.setFields(new ItemFields[]{
ItemFields.Overview,
ItemFields.PrimaryImageAspectRatio,
ItemFields.ChildCount
});
recordings.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
recordings.setFields(new ItemFields[]{ItemFields.Overview, ItemFields.PrimaryImageAspectRatio});
recordings.setUserId(TvApp.getApplication().getCurrentUser().getId());
recordings.setEnableImages(true);
recordings.setLimit(40);
mRows.add(new BrowseRowDef(getString(R.string.lbl_recent_recordings), recordings, 40));
mRows.add(new BrowseRowDef(mActivity.getString(R.string.lbl_recent_recordings), recordings, 40));
//Movies
RecordingQuery movies = new RecordingQuery();
movies.setFields(new ItemFields[]{
ItemFields.Overview,
ItemFields.PrimaryImageAspectRatio,
ItemFields.ChildCount
});
movies.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
movies.setFields(new ItemFields[]{ItemFields.Overview, ItemFields.PrimaryImageAspectRatio});
movies.setUserId(TvApp.getApplication().getCurrentUser().getId());
movies.setEnableImages(true);
movies.setIsMovie(true);
BrowseRowDef moviesDef = new BrowseRowDef(getString(R.string.lbl_movies), movies, 60);
BrowseRowDef moviesDef = new BrowseRowDef(mActivity.getString(R.string.lbl_movies), movies, 60);
//Shows
RecordingQuery shows = new RecordingQuery();
shows.setFields(new ItemFields[]{
ItemFields.Overview,
ItemFields.PrimaryImageAspectRatio,
ItemFields.ChildCount
});
shows.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
shows.setFields(new ItemFields[]{ItemFields.Overview, ItemFields.PrimaryImageAspectRatio});
shows.setUserId(TvApp.getApplication().getCurrentUser().getId());
shows.setEnableImages(true);
shows.setIsSeries(true);
BrowseRowDef showsDef = new BrowseRowDef(getString(R.string.lbl_tv_series), shows, 60);
BrowseRowDef showsDef = new BrowseRowDef(mActivity.getString(R.string.lbl_tv_series), shows, 60);
mRows.add(showsDef);
mRows.add(moviesDef);
//Insert order based on pref
if (mApplication.getDisplayPriority() == DisplayPriorityType.Movies) {
mRows.add(moviesDef);
mRows.add(showsDef);
} else {
mRows.add(showsDef);
mRows.add(moviesDef);
}
//Sports
RecordingQuery sports = new RecordingQuery();
sports.setFields(new ItemFields[]{
ItemFields.Overview,
ItemFields.PrimaryImageAspectRatio,
ItemFields.ChildCount
});
sports.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
sports.setFields(new ItemFields[]{ItemFields.Overview, ItemFields.PrimaryImageAspectRatio});
sports.setUserId(TvApp.getApplication().getCurrentUser().getId());
sports.setEnableImages(true);
sports.setIsSports(true);
mRows.add(new BrowseRowDef(getString(R.string.lbl_sports), sports, 60));
mRows.add(new BrowseRowDef(mActivity.getString(R.string.lbl_sports), sports, 60));
//Kids
RecordingQuery kids = new RecordingQuery();
kids.setFields(new ItemFields[]{
ItemFields.Overview,
ItemFields.PrimaryImageAspectRatio,
ItemFields.ChildCount
});
kids.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
kids.setFields(new ItemFields[]{ItemFields.Overview, ItemFields.PrimaryImageAspectRatio});
kids.setUserId(TvApp.getApplication().getCurrentUser().getId());
kids.setEnableImages(true);
kids.setIsKids(true);
mRows.add(new BrowseRowDef(getString(R.string.lbl_kids), kids, 60));
mRows.add(new BrowseRowDef(mActivity.getString(R.string.lbl_kids), kids, 60));
//All Recordings by group - will only be there for non-internal TV
RecordingGroupQuery recordingGroups = new RecordingGroupQuery();
recordingGroups.setUserId(TvApp.getApplication().getCurrentUser().getId());
mRows.add(new BrowseRowDef(mActivity.getString(R.string.lbl_all_recordings), recordingGroups));
rowLoader.loadRows(mRows);
addNext24Timers();
@@ -123,11 +105,9 @@ public class BrowseRecordingsFragment extends EnhancedBrowseFragment {
private void addNext24Timers() {
final TimerQuery scheduled = new TimerQuery();
final long ticks24 = 1000 * 60 * 60 * 24;
KoinJavaComponent.<ApiClient>get(ApiClient.class).GetLiveTvTimersAsync(scheduled, new LifecycleAwareResponse<TimerInfoDtoResult>(getLifecycle()) {
TvApp.getApplication().getApiClient().GetLiveTvTimersAsync(scheduled, new Response<TimerInfoDtoResult>() {
@Override
public void onResponse(TimerInfoDtoResult response) {
if (!getActive()) return;
List<BaseItemDto> nearTimers = new ArrayList<>();
long next24 = System.currentTimeMillis() + ticks24;
//Get scheduled items for next 24 hours
@@ -139,7 +119,7 @@ public class BrowseRecordingsFragment extends EnhancedBrowseFragment {
programInfo.setId(timer.getId());
programInfo.setChannelName(timer.getChannelName());
programInfo.setName(Utils.getSafeValue(timer.getName(), "Unknown"));
Timber.w("No program info for timer %s. Creating one...", programInfo.getName());
TvApp.getApplication().getLogger().Warn("No program info for timer %s. Creating one...", programInfo.getName());
programInfo.setBaseItemType(BaseItemType.Program);
programInfo.setTimerId(timer.getId());
programInfo.setSeriesTimerId(timer.getSeriesTimerId());
@@ -151,15 +131,13 @@ public class BrowseRecordingsFragment extends EnhancedBrowseFragment {
}
}
if (nearTimers.size() > 0) {
ItemRowAdapter scheduledAdapter = new ItemRowAdapter(requireContext(), nearTimers, mCardPresenter, mRowsAdapter, true);
ItemRowAdapter scheduledAdapter = new ItemRowAdapter(nearTimers, mCardPresenter, mRowsAdapter, true);
scheduledAdapter.Retrieve();
ListRow scheduleRow = new ListRow(new HeaderItem("Scheduled in Next 24 Hours"), scheduledAdapter);
mRowsAdapter.add(0, scheduleRow);
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
if (!getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) return;
mRowsFragment.setSelectedPosition(0, true);
}
}, 500);
@@ -168,22 +146,22 @@ public class BrowseRecordingsFragment extends EnhancedBrowseFragment {
@Override
public void onError(Exception exception) {
if (!getActive()) return;
Utils.showToast(getContext(), exception.getLocalizedMessage());
}
Utils.showToast(mApplication, exception.getLocalizedMessage());
}
});
}
@Override
protected void addAdditionalRows(MutableObjectAdapter<Row> rowAdapter) {
HeaderItem gridHeader = new HeaderItem(rowAdapter.size(), getString(R.string.lbl_views));
protected void addAdditionalRows(ArrayObjectAdapter rowAdapter) {
HeaderItem gridHeader = new HeaderItem(rowAdapter.size(), mApplication.getString(R.string.lbl_views));
GridButtonPresenter mGridPresenter = new GridButtonPresenter();
ArrayObjectAdapter gridRowAdapter = new ArrayObjectAdapter(mGridPresenter);
gridRowAdapter.add(new GridButton(SCHEDULE, getString(R.string.lbl_schedule), R.drawable.tile_port_time));
gridRowAdapter.add(new GridButton(SERIES, getString(R.string.lbl_series_recordings), R.drawable.tile_port_series_timer));
gridRowAdapter.add(new GridButton(SCHEDULE, TvApp.getApplication().getString(R.string.lbl_schedule), R.drawable.tile_port_time));
gridRowAdapter.add(new GridButton(SERIES, mActivity.getString(R.string.lbl_series_recordings), R.drawable.tile_port_series_timer));
rowAdapter.add(new ListRow(gridHeader, gridRowAdapter));
}
}

View File

@@ -1,10 +1,12 @@
package org.jellyfin.androidtv.ui.browsing;
package org.jellyfin.androidtv.browsing;
import org.jellyfin.androidtv.model.ChangeTriggerType;
import org.jellyfin.androidtv.querying.QueryType;
import org.jellyfin.androidtv.querying.ViewQuery;
import org.jellyfin.androidtv.constant.ChangeTriggerType;
import org.jellyfin.androidtv.constant.QueryType;
import org.jellyfin.androidtv.data.querying.ViewQuery;
import org.jellyfin.apiclient.model.livetv.LiveTvChannelQuery;
import org.jellyfin.apiclient.model.livetv.RecommendedProgramQuery;
import org.jellyfin.apiclient.model.livetv.RecordingGroupQuery;
import org.jellyfin.apiclient.model.livetv.RecordingQuery;
import org.jellyfin.apiclient.model.livetv.SeriesTimerQuery;
import org.jellyfin.apiclient.model.querying.ArtistsQuery;
@@ -16,6 +18,9 @@ import org.jellyfin.apiclient.model.querying.SeasonQuery;
import org.jellyfin.apiclient.model.querying.SimilarItemsQuery;
import org.jellyfin.apiclient.model.querying.UpcomingEpisodesQuery;
/**
* Created by Eric on 12/4/2014.
*/
public class BrowseRowDef {
private String headerText;
private ItemQuery query;
@@ -29,6 +34,7 @@ public class BrowseRowDef {
private LiveTvChannelQuery tvChannelQuery;
private RecommendedProgramQuery programQuery;
private RecordingQuery recordingQuery;
private RecordingGroupQuery recordingGroupQuery;
private SeriesTimerQuery seriesTimerQuery;
private ArtistsQuery artistsQuery;
@@ -109,7 +115,6 @@ public class BrowseRowDef {
headerText = header;
this.latestItemsQuery = query;
this.queryType = QueryType.LatestItems;
this.staticHeight = true;
this.changeTriggers = changeTriggers;
}
@@ -143,6 +148,12 @@ public class BrowseRowDef {
this.queryType = QueryType.LiveTvRecording;
}
public BrowseRowDef(String header, RecordingGroupQuery query) {
headerText = header;
this.recordingGroupQuery = query;
this.queryType = QueryType.LiveTvRecordingGroup;
}
public BrowseRowDef(String header, PersonsQuery query, int chunkSize) {
headerText = header;
this.personsQuery = query;
@@ -233,4 +244,9 @@ public class BrowseRowDef {
public ChangeTriggerType[] getChangeTriggers() {
return changeTriggers;
}
public RecordingGroupQuery getRecordingGroupQuery() {
return recordingGroupQuery;
}
}

View File

@@ -0,0 +1,15 @@
package org.jellyfin.androidtv.browsing;
import android.os.Bundle;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.base.BaseActivity;
public class BrowseScheduleActivity extends BaseActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_schedule);
}
}

View File

@@ -0,0 +1,32 @@
package org.jellyfin.androidtv.browsing;
import org.jellyfin.androidtv.livetv.TvManager;
import org.jellyfin.androidtv.presentation.CardPresenter;
import org.jellyfin.apiclient.interaction.Response;
import org.jellyfin.apiclient.model.livetv.TimerQuery;
/**
* Created by Eric on 9/3/2015.
*/
public class BrowseScheduleFragment extends EnhancedBrowseFragment {
@Override
public void onResume() {
super.onResume();
}
@Override
protected void setupQueries(final IRowLoader rowLoader) {
TvManager.getScheduleRowsAsync(new TimerQuery(), new CardPresenter(true), mRowsAdapter, new Response<Integer>() {
@Override
public void onResponse(Integer response) {
if (response == 0) mActivity.setTitle("No Scheduled Recordings");
}
});
}
}

View File

@@ -1,33 +1,31 @@
package org.jellyfin.androidtv.ui.browsing;
import static org.koin.java.KoinJavaComponent.inject;
package org.jellyfin.androidtv.browsing;
import androidx.leanback.widget.ArrayObjectAdapter;
import androidx.leanback.widget.HeaderItem;
import androidx.leanback.widget.ListRow;
import androidx.leanback.widget.Row;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.auth.repository.UserRepository;
import org.jellyfin.androidtv.constant.ChangeTriggerType;
import org.jellyfin.androidtv.constant.LiveTvOption;
import org.jellyfin.androidtv.constant.QueryType;
import org.jellyfin.androidtv.data.querying.StdItemQuery;
import org.jellyfin.androidtv.preference.UserPreferences;
import org.jellyfin.androidtv.TvApp;
import org.jellyfin.androidtv.itemhandling.ItemRowAdapter;
import org.jellyfin.androidtv.model.ChangeTriggerType;
import org.jellyfin.androidtv.presentation.GridButtonPresenter;
import org.jellyfin.androidtv.querying.QueryType;
import org.jellyfin.androidtv.querying.StdItemQuery;
import org.jellyfin.androidtv.ui.GridButton;
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapter;
import org.jellyfin.androidtv.ui.presentation.GridButtonPresenter;
import org.jellyfin.androidtv.ui.presentation.MutableObjectAdapter;
import org.jellyfin.androidtv.util.TimeUtils;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.androidtv.util.apiclient.LifecycleAwareResponse;
import org.jellyfin.apiclient.interaction.ApiClient;
import java.util.ArrayList;
import java.util.List;
import org.jellyfin.apiclient.interaction.Response;
import org.jellyfin.apiclient.model.dto.BaseItemDto;
import org.jellyfin.apiclient.model.dto.BaseItemType;
import org.jellyfin.apiclient.model.entities.LocationType;
import org.jellyfin.apiclient.model.entities.SortOrder;
import org.jellyfin.apiclient.model.livetv.LiveTvChannelQuery;
import org.jellyfin.apiclient.model.livetv.RecommendedProgramQuery;
import org.jellyfin.apiclient.model.livetv.RecordingGroupQuery;
import org.jellyfin.apiclient.model.livetv.RecordingQuery;
import org.jellyfin.apiclient.model.livetv.SeriesTimerQuery;
import org.jellyfin.apiclient.model.livetv.TimerInfoDto;
@@ -35,46 +33,27 @@ import org.jellyfin.apiclient.model.livetv.TimerQuery;
import org.jellyfin.apiclient.model.querying.ItemFields;
import org.jellyfin.apiclient.model.querying.ItemFilter;
import org.jellyfin.apiclient.model.querying.ItemQuery;
import org.jellyfin.apiclient.model.querying.ItemSortBy;
import org.jellyfin.apiclient.model.querying.ItemsResult;
import org.jellyfin.apiclient.model.querying.LatestItemsQuery;
import org.jellyfin.apiclient.model.querying.NextUpQuery;
import org.jellyfin.apiclient.model.results.TimerInfoDtoResult;
import org.jellyfin.sdk.model.constant.CollectionType;
import org.jellyfin.sdk.model.constant.ItemSortBy;
import org.koin.java.KoinJavaComponent;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import kotlin.Lazy;
import timber.log.Timber;
public class BrowseViewFragment extends EnhancedBrowseFragment {
private boolean isLiveTvLibrary;
private Lazy<ApiClient> apiClient = inject(ApiClient.class);
@Override
protected void setupQueries(final RowLoader rowLoader) {
protected void setupQueries(final IRowLoader rowLoader) {
String type = mFolder.getCollectionType() != null ? mFolder.getCollectionType().toLowerCase() : "";
switch (type) {
case CollectionType.Movies:
case "movies":
itemTypeString = "Movie";
//Resume
StdItemQuery resumeMovies = new StdItemQuery(new ItemFields[]{
ItemFields.PrimaryImageAspectRatio,
ItemFields.Overview,
ItemFields.ItemCounts,
ItemFields.DisplayPreferencesId,
ItemFields.ChildCount,
ItemFields.MediaStreams,
ItemFields.MediaSources
});
StdItemQuery resumeMovies = new StdItemQuery();
resumeMovies.setIncludeItemTypes(new String[]{"Movie"});
resumeMovies.setRecursive(true);
resumeMovies.setParentId(mFolder.getId().toString());
resumeMovies.setParentId(mFolder.getId());
resumeMovies.setImageTypeLimit(1);
resumeMovies.setLimit(50);
resumeMovies.setCollapseBoxSetItems(false);
@@ -82,83 +61,55 @@ public class BrowseViewFragment extends EnhancedBrowseFragment {
resumeMovies.setFilters(new ItemFilter[]{ItemFilter.IsResumable});
resumeMovies.setSortBy(new String[]{ItemSortBy.DatePlayed});
resumeMovies.setSortOrder(SortOrder.Descending);
mRows.add(new BrowseRowDef(getString(R.string.lbl_continue_watching), resumeMovies, 0, new ChangeTriggerType[]{ChangeTriggerType.MoviePlayback}));
mRows.add(new BrowseRowDef(mApplication.getString(R.string.lbl_continue_watching), resumeMovies, 0, new ChangeTriggerType[]{ChangeTriggerType.MoviePlayback}));
//Latest
LatestItemsQuery latestMovies = new LatestItemsQuery();
latestMovies.setFields(new ItemFields[]{
ItemFields.PrimaryImageAspectRatio,
ItemFields.Overview,
ItemFields.ChildCount,
ItemFields.MediaSources,
ItemFields.MediaStreams
});
latestMovies.setParentId(mFolder.getId().toString());
latestMovies.setFields(new ItemFields[]{ItemFields.PrimaryImageAspectRatio, ItemFields.Overview});
latestMovies.setParentId(mFolder.getId());
latestMovies.setLimit(50);
latestMovies.setImageTypeLimit(1);
mRows.add(new BrowseRowDef(getString(R.string.lbl_latest), latestMovies, new ChangeTriggerType[]{ChangeTriggerType.MoviePlayback, ChangeTriggerType.LibraryUpdated}));
mRows.add(new BrowseRowDef(mApplication.getString(R.string.lbl_latest), latestMovies, new ChangeTriggerType[]{ChangeTriggerType.MoviePlayback, ChangeTriggerType.LibraryUpdated}));
//Favorites
StdItemQuery favorites = new StdItemQuery(new ItemFields[]{
ItemFields.PrimaryImageAspectRatio,
ItemFields.Overview,
ItemFields.ItemCounts,
ItemFields.DisplayPreferencesId,
ItemFields.ChildCount,
ItemFields.MediaStreams,
ItemFields.MediaSources
});
StdItemQuery favorites = new StdItemQuery();
favorites.setIncludeItemTypes(new String[]{"Movie"});
favorites.setRecursive(true);
favorites.setParentId(mFolder.getId().toString());
favorites.setParentId(mFolder.getId());
favorites.setImageTypeLimit(1);
favorites.setFilters(new ItemFilter[]{ItemFilter.IsFavorite});
favorites.setSortBy(new String[]{ItemSortBy.SortName});
mRows.add(new BrowseRowDef(getString(R.string.lbl_favorites), favorites, 60, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated, ChangeTriggerType.FavoriteUpdate}));
mRows.add(new BrowseRowDef(mApplication.getString(R.string.lbl_favorites), favorites, 60, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated, ChangeTriggerType.FavoriteUpdate}));
//Collections
StdItemQuery collections = new StdItemQuery();
collections.setFields(new ItemFields[]{
ItemFields.ChildCount
});
collections.setIncludeItemTypes(new String[]{"BoxSet"});
collections.setRecursive(true);
collections.setImageTypeLimit(1);
//collections.setParentId(mFolder.getId());
collections.setSortBy(new String[]{ItemSortBy.SortName});
mRows.add(new BrowseRowDef(getString(R.string.lbl_collections), collections, 60, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated}));
mRows.add(new BrowseRowDef(mApplication.getString(R.string.lbl_collections), collections, 60, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated}));
rowLoader.loadRows(mRows);
break;
case CollectionType.TvShows:
case "tvshows":
itemTypeString = "Series";
//Next up
NextUpQuery nextUpQuery = new NextUpQuery();
nextUpQuery.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
nextUpQuery.setUserId(TvApp.getApplication().getCurrentUser().getId());
nextUpQuery.setLimit(50);
nextUpQuery.setParentId(mFolder.getId().toString());
nextUpQuery.setParentId(mFolder.getId());
nextUpQuery.setImageTypeLimit(1);
nextUpQuery.setFields(new ItemFields[]{
ItemFields.PrimaryImageAspectRatio,
ItemFields.Overview,
ItemFields.ChildCount,
ItemFields.MediaSources,
ItemFields.MediaStreams
});
mRows.add(new BrowseRowDef(getString(R.string.lbl_next_up), nextUpQuery, new ChangeTriggerType[]{ChangeTriggerType.TvPlayback}));
nextUpQuery.setFields(new ItemFields[]{ItemFields.PrimaryImageAspectRatio, ItemFields.Overview});
mRows.add(new BrowseRowDef(mApplication.getResources().getString(R.string.lbl_next_up), nextUpQuery, new ChangeTriggerType[]{ChangeTriggerType.TvPlayback}));
//Premieres
if (KoinJavaComponent.<UserPreferences>get(UserPreferences.class).get(UserPreferences.Companion.getPremieresEnabled())) {
StdItemQuery newQuery = new StdItemQuery(new ItemFields[]{
ItemFields.DateCreated,
ItemFields.PrimaryImageAspectRatio,
ItemFields.Overview,
ItemFields.ChildCount
});
newQuery.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
if (mApplication.getPrefs().getBoolean("pref_enable_premieres", false)) {
StdItemQuery newQuery = new StdItemQuery(new ItemFields[]{ItemFields.DateCreated, ItemFields.PrimaryImageAspectRatio, ItemFields.Overview});
newQuery.setUserId(TvApp.getApplication().getCurrentUser().getId());
newQuery.setIncludeItemTypes(new String[]{"Episode"});
newQuery.setParentId(mFolder.getId().toString());
newQuery.setParentId(mFolder.getId());
newQuery.setRecursive(true);
newQuery.setIsVirtualUnaired(false);
newQuery.setIsMissing(false);
@@ -168,165 +119,134 @@ public class BrowseViewFragment extends EnhancedBrowseFragment {
newQuery.setSortOrder(SortOrder.Descending);
newQuery.setEnableTotalRecordCount(false);
newQuery.setLimit(300);
mRows.add(new BrowseRowDef(getString(R.string.lbl_new_premieres), newQuery, 0, true, true, new ChangeTriggerType[]{ChangeTriggerType.TvPlayback}, QueryType.Premieres));
mRows.add(new BrowseRowDef(mApplication.getString(R.string.lbl_new_premieres), newQuery, 0, true, true, new ChangeTriggerType[]{ChangeTriggerType.TvPlayback}, QueryType.Premieres));
}
//Latest content added
LatestItemsQuery latestSeries = new LatestItemsQuery();
latestSeries.setFields(new ItemFields[]{
ItemFields.PrimaryImageAspectRatio,
ItemFields.Overview,
ItemFields.ChildCount,
ItemFields.MediaSources,
ItemFields.MediaStreams
});
latestSeries.setFields(new ItemFields[]{ItemFields.PrimaryImageAspectRatio, ItemFields.Overview});
latestSeries.setIncludeItemTypes(new String[]{"Episode"});
latestSeries.setGroupItems(true);
latestSeries.setParentId(mFolder.getId().toString());
latestSeries.setParentId(mFolder.getId());
latestSeries.setLimit(50);
latestSeries.setImageTypeLimit(1);
mRows.add(new BrowseRowDef(getString(R.string.lbl_latest), latestSeries, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated}));
mRows.add(new BrowseRowDef(mApplication.getString(R.string.lbl_latest), latestSeries, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated}));
//Favorites
StdItemQuery tvFavorites = new StdItemQuery();
tvFavorites.setIncludeItemTypes(new String[]{"Series"});
tvFavorites.setRecursive(true);
tvFavorites.setParentId(mFolder.getId().toString());
tvFavorites.setParentId(mFolder.getId());
tvFavorites.setImageTypeLimit(1);
tvFavorites.setFilters(new ItemFilter[]{ItemFilter.IsFavorite});
tvFavorites.setSortBy(new String[]{ItemSortBy.SortName});
mRows.add(new BrowseRowDef(getString(R.string.lbl_favorites), tvFavorites, 60, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated, ChangeTriggerType.FavoriteUpdate}));
mRows.add(new BrowseRowDef(mApplication.getString(R.string.lbl_favorites), tvFavorites, 60, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated, ChangeTriggerType.FavoriteUpdate}));
rowLoader.loadRows(mRows);
break;
case CollectionType.Music:
case "music":
//Latest
LatestItemsQuery latestAlbums = new LatestItemsQuery();
latestAlbums.setFields(new ItemFields[]{
ItemFields.PrimaryImageAspectRatio,
ItemFields.Overview,
ItemFields.ChildCount
});
latestAlbums.setFields(new ItemFields[]{ItemFields.PrimaryImageAspectRatio, ItemFields.Overview});
latestAlbums.setIncludeItemTypes(new String[]{"Audio"});
latestAlbums.setGroupItems(true);
latestAlbums.setImageTypeLimit(1);
latestAlbums.setParentId(mFolder.getId().toString());
latestAlbums.setParentId(mFolder.getId());
latestAlbums.setLimit(50);
mRows.add(new BrowseRowDef(getString(R.string.lbl_latest), latestAlbums, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated}));
mRows.add(new BrowseRowDef(mApplication.getString(R.string.lbl_latest), latestAlbums, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated}));
//Last Played
StdItemQuery lastPlayed = new StdItemQuery();
lastPlayed.setIncludeItemTypes(new String[]{"Audio"});
lastPlayed.setRecursive(true);
lastPlayed.setParentId(mFolder.getId().toString());
lastPlayed.setParentId(mFolder.getId());
lastPlayed.setImageTypeLimit(1);
lastPlayed.setFilters(new ItemFilter[]{ItemFilter.IsPlayed});
lastPlayed.setSortBy(new String[]{ItemSortBy.DatePlayed});
lastPlayed.setSortOrder(SortOrder.Descending);
lastPlayed.setEnableTotalRecordCount(false);
lastPlayed.setLimit(50);
mRows.add(new BrowseRowDef(getString(R.string.lbl_last_played), lastPlayed, 0, false, true, new ChangeTriggerType[]{ChangeTriggerType.MusicPlayback, ChangeTriggerType.LibraryUpdated}));
mRows.add(new BrowseRowDef(mApplication.getString(R.string.lbl_last_played), lastPlayed, 0, false, true, new ChangeTriggerType[]{ChangeTriggerType.MusicPlayback, ChangeTriggerType.LibraryUpdated}));
//Favorites
StdItemQuery favAlbums = new StdItemQuery();
favAlbums.setIncludeItemTypes(new String[]{"MusicAlbum", "MusicArtist"});
favAlbums.setRecursive(true);
favAlbums.setParentId(mFolder.getId().toString());
favAlbums.setParentId(mFolder.getId());
favAlbums.setImageTypeLimit(1);
favAlbums.setFilters(new ItemFilter[]{ItemFilter.IsFavorite});
favAlbums.setSortBy(new String[]{ItemSortBy.SortName});
mRows.add(new BrowseRowDef(getString(R.string.lbl_favorites), favAlbums, 60, false, true, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated, ChangeTriggerType.FavoriteUpdate}));
mRows.add(new BrowseRowDef(mApplication.getString(R.string.lbl_favorites), favAlbums, 60, false, true, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated, ChangeTriggerType.FavoriteUpdate}));
//AudioPlaylists
StdItemQuery playlists = new StdItemQuery(new ItemFields[]{
ItemFields.PrimaryImageAspectRatio,
ItemFields.CumulativeRunTimeTicks,
ItemFields.ChildCount
});
StdItemQuery playlists = new StdItemQuery(new ItemFields[]{ItemFields.PrimaryImageAspectRatio, ItemFields.CumulativeRunTimeTicks});
playlists.setIncludeItemTypes(new String[]{"Playlist"});
playlists.setImageTypeLimit(1);
playlists.setRecursive(true);
playlists.setSortBy(new String[]{ItemSortBy.DateCreated});
playlists.setSortOrder(SortOrder.Descending);
mRows.add(new BrowseRowDef(getString(R.string.lbl_playlists), playlists, 60, false, true, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated}, QueryType.AudioPlaylists));
mRows.add(new BrowseRowDef(mApplication.getString(R.string.lbl_playlists), playlists, 60, false, true, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated}, QueryType.AudioPlaylists));
rowLoader.loadRows(mRows);
break;
case CollectionType.LiveTv:
case "livetv":
isLiveTvLibrary = true;
showViews = true;
//On now
RecommendedProgramQuery onNow = new RecommendedProgramQuery();
onNow.setIsAiring(true);
onNow.setFields(new ItemFields[]{
ItemFields.Overview,
ItemFields.PrimaryImageAspectRatio,
ItemFields.ChannelInfo,
ItemFields.ChildCount
});
onNow.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
onNow.setFields(new ItemFields[]{ItemFields.Overview, ItemFields.PrimaryImageAspectRatio, ItemFields.ChannelInfo});
onNow.setUserId(TvApp.getApplication().getCurrentUser().getId());
onNow.setImageTypeLimit(1);
onNow.setEnableTotalRecordCount(false);
onNow.setLimit(150);
mRows.add(new BrowseRowDef(getString(R.string.lbl_on_now), onNow));
mRows.add(new BrowseRowDef(mApplication.getString(R.string.lbl_on_now), onNow));
//Upcoming
RecommendedProgramQuery upcomingTv = new RecommendedProgramQuery();
upcomingTv.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
upcomingTv.setFields(new ItemFields[]{
ItemFields.Overview,
ItemFields.PrimaryImageAspectRatio,
ItemFields.ChannelInfo,
ItemFields.ChildCount
});
upcomingTv.setUserId(TvApp.getApplication().getCurrentUser().getId());
upcomingTv.setFields(new ItemFields[]{ItemFields.Overview, ItemFields.PrimaryImageAspectRatio, ItemFields.ChannelInfo});
upcomingTv.setIsAiring(false);
upcomingTv.setHasAired(false);
upcomingTv.setImageTypeLimit(1);
upcomingTv.setEnableTotalRecordCount(false);
upcomingTv.setLimit(150);
mRows.add(new BrowseRowDef(getString(R.string.lbl_coming_up), upcomingTv));
mRows.add(new BrowseRowDef(mApplication.getString(R.string.lbl_coming_up), upcomingTv));
//Fav Channels
LiveTvChannelQuery favTv = new LiveTvChannelQuery();
favTv.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
favTv.setUserId(TvApp.getApplication().getCurrentUser().getId());
favTv.setEnableFavoriteSorting(true);
favTv.setIsFavorite(true);
mRows.add(new BrowseRowDef(getString(R.string.lbl_favorite_channels), favTv));
mRows.add(new BrowseRowDef(mApplication.getString(R.string.lbl_favorite_channels), favTv));
//Other Channels
LiveTvChannelQuery otherTv = new LiveTvChannelQuery();
otherTv.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
otherTv.setUserId(TvApp.getApplication().getCurrentUser().getId());
otherTv.setIsFavorite(false);
mRows.add(new BrowseRowDef(getString(R.string.lbl_other_channels), otherTv));
mRows.add(new BrowseRowDef(mApplication.getString(R.string.lbl_other_channels), otherTv));
//Latest Recordings
RecordingQuery recordings = new RecordingQuery();
recordings.setFields(new ItemFields[]{
ItemFields.Overview,
ItemFields.PrimaryImageAspectRatio,
ItemFields.ChildCount
});
recordings.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
recordings.setFields(new ItemFields[]{ItemFields.Overview, ItemFields.PrimaryImageAspectRatio});
recordings.setUserId(TvApp.getApplication().getCurrentUser().getId());
recordings.setEnableImages(true);
recordings.setLimit(40);
//Do a straight query and then split the returned items into logical groups
apiClient.getValue().GetLiveTvRecordingsAsync(recordings, new LifecycleAwareResponse<ItemsResult>(getLifecycle()) {
TvApp.getApplication().getApiClient().GetLiveTvRecordingsAsync(recordings, new Response<ItemsResult>() {
@Override
public void onResponse(ItemsResult response) {
if (!getActive()) return;
final ItemsResult recordingsResponse = response;
final long ticks24 = 1000 * 60 * 60 * 24;
// Also get scheduled recordings for next 24 hours
final TimerQuery scheduled = new TimerQuery();
apiClient.getValue().GetLiveTvTimersAsync(scheduled, new LifecycleAwareResponse<TimerInfoDtoResult>(getLifecycle()) {
TvApp.getApplication().getApiClient().GetLiveTvTimersAsync(scheduled, new Response<TimerInfoDtoResult>() {
@Override
public void onResponse(TimerInfoDtoResult response) {
if (!getActive()) return;
List<BaseItemDto> nearTimers = new ArrayList<>();
long next24 = System.currentTimeMillis() + ticks24;
//Get scheduled items for next 24 hours
@@ -338,7 +258,7 @@ public class BrowseViewFragment extends EnhancedBrowseFragment {
programInfo.setId(timer.getId());
programInfo.setChannelName(timer.getChannelName());
programInfo.setName(Utils.getSafeValue(timer.getName(), "Unknown"));
Timber.w("No program info for timer %s. Creating one...", programInfo.getName());
TvApp.getApplication().getLogger().Warn("No program info for timer %s. Creating one...", programInfo.getName());
programInfo.setBaseItemType(BaseItemType.Program);
programInfo.setTimerId(timer.getId());
programInfo.setSeriesTimerId(timer.getSeriesTimerId());
@@ -369,31 +289,31 @@ public class BrowseViewFragment extends EnhancedBrowseFragment {
//First put all recordings in and retrieve
//All Recordings
RecordingQuery recordings = new RecordingQuery();
recordings.setFields(new ItemFields[]{
ItemFields.Overview,
ItemFields.PrimaryImageAspectRatio,
ItemFields.ChildCount
});
recordings.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
recordings.setFields(new ItemFields[]{ItemFields.Overview, ItemFields.PrimaryImageAspectRatio});
recordings.setUserId(TvApp.getApplication().getCurrentUser().getId());
recordings.setEnableImages(true);
mRows.add(new BrowseRowDef(getString(R.string.lbl_recent_recordings), recordings, 50));
mRows.add(new BrowseRowDef(mActivity.getString(R.string.lbl_recent_recordings), recordings, 50));
//All Recordings by group - will only be there for non-internal TV
RecordingGroupQuery recordingGroups = new RecordingGroupQuery();
recordingGroups.setUserId(TvApp.getApplication().getCurrentUser().getId());
mRows.add(new BrowseRowDef(mActivity.getString(R.string.lbl_all_recordings), recordingGroups));
rowLoader.loadRows(mRows);
//Now insert our smart rows
if (weekItems.size() > 0) {
ItemRowAdapter weekAdapter = new ItemRowAdapter(requireContext(), weekItems, mCardPresenter, mRowsAdapter, true);
ItemRowAdapter weekAdapter = new ItemRowAdapter(weekItems, mCardPresenter, mRowsAdapter, true);
weekAdapter.Retrieve();
ListRow weekRow = new ListRow(new HeaderItem("Past Week"), weekAdapter);
mRowsAdapter.add(0, weekRow);
}
if (nearTimers.size() > 0) {
ItemRowAdapter scheduledAdapter = new ItemRowAdapter(requireContext(), nearTimers, mCardPresenter, mRowsAdapter, true);
ItemRowAdapter scheduledAdapter = new ItemRowAdapter(nearTimers, mCardPresenter, mRowsAdapter, true);
scheduledAdapter.Retrieve();
ListRow scheduleRow = new ListRow(new HeaderItem("Scheduled in Next 24 Hours"), scheduledAdapter);
mRowsAdapter.add(0, scheduleRow);
}
if (dayItems.size() > 0) {
ItemRowAdapter dayAdapter = new ItemRowAdapter(requireContext(), dayItems, mCardPresenter, mRowsAdapter, true);
ItemRowAdapter dayAdapter = new ItemRowAdapter(dayItems, mCardPresenter, mRowsAdapter, true);
dayAdapter.Retrieve();
ListRow dayRow = new ListRow(new HeaderItem("Past 24 Hours"), dayAdapter);
mRowsAdapter.add(0, dayRow);
@@ -403,7 +323,7 @@ public class BrowseViewFragment extends EnhancedBrowseFragment {
// no recordings
rowLoader.loadRows(mRows);
if (nearTimers.size() > 0) {
ItemRowAdapter scheduledAdapter = new ItemRowAdapter(requireContext(), nearTimers, mCardPresenter, mRowsAdapter, true);
ItemRowAdapter scheduledAdapter = new ItemRowAdapter(nearTimers, mCardPresenter, mRowsAdapter, true);
scheduledAdapter.Retrieve();
ListRow scheduleRow = new ListRow(new HeaderItem("Scheduled in Next 24 Hours"), scheduledAdapter);
mRowsAdapter.add(0, scheduleRow);
@@ -418,39 +338,35 @@ public class BrowseViewFragment extends EnhancedBrowseFragment {
@Override
public void onError(Exception exception) {
if (!getActive()) return;
Utils.showToast(getContext(), exception.getLocalizedMessage());
Utils.showToast(mApplication, exception.getLocalizedMessage());
}
});
break;
case "seriestimers":
mRows.add(new BrowseRowDef(getString(R.string.lbl_series_recordings), new SeriesTimerQuery()));
mRows.add(new BrowseRowDef(mActivity.getString(R.string.lbl_series_recordings), new SeriesTimerQuery()));
rowLoader.loadRows(mRows);
break;
default:
// Fall back to rows defined by the view children
final List<BrowseRowDef> rows = new ArrayList<>();
final UUID userId = KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId();
final String userId = TvApp.getApplication().getCurrentUser().getId();
ItemQuery query = new ItemQuery();
query.setParentId(mFolder.getId().toString());
query.setUserId(userId.toString());
query.setParentId(mFolder.getId());
query.setUserId(userId);
query.setImageTypeLimit(1);
query.setSortBy(new String[]{ItemSortBy.SortName});
apiClient.getValue().GetItemsAsync(query, new LifecycleAwareResponse<ItemsResult>(getLifecycle()) {
TvApp.getApplication().getApiClient().GetItemsAsync(query, new Response<ItemsResult>() {
@Override
public void onResponse(ItemsResult response) {
if (!getActive()) return;
if (response.getTotalRecordCount() > 0) {
for (BaseItemDto item : response.getItems()) {
ItemQuery rowQuery = new StdItemQuery();
rowQuery.setParentId(item.getId());
rowQuery.setUserId(userId.toString());
rowQuery.setUserId(userId);
rows.add(new BrowseRowDef(item.getName(), rowQuery, 60, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated}));
}
}
@@ -468,34 +384,17 @@ public class BrowseViewFragment extends EnhancedBrowseFragment {
}
@Override
protected void addAdditionalRows(MutableObjectAdapter<Row> rowAdapter) {
protected void addAdditionalRows(ArrayObjectAdapter rowAdapter) {
if (isLiveTvLibrary) {
//Views row
HeaderItem gridHeader = new HeaderItem(mRowsAdapter.size(), getString(R.string.lbl_views));
HeaderItem gridHeader = new HeaderItem(mRowsAdapter.size(), mApplication.getString(R.string.lbl_views));
GridButtonPresenter mGridPresenter = new GridButtonPresenter();
ArrayObjectAdapter gridRowAdapter = new ArrayObjectAdapter(mGridPresenter);
gridRowAdapter.add(new GridButton(
LiveTvOption.LIVE_TV_GUIDE_OPTION_ID,
getString(R.string.lbl_live_tv_guide),
R.drawable.tile_port_guide
));
gridRowAdapter.add(new GridButton(
LiveTvOption.LIVE_TV_RECORDINGS_OPTION_ID,
getString(R.string.lbl_recorded_tv),
R.drawable.tile_port_record
));
if (Utils.canManageRecordings(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue())) {
gridRowAdapter.add(new GridButton(
LiveTvOption.LIVE_TV_SCHEDULE_OPTION_ID,
getString(R.string.lbl_schedule),
R.drawable.tile_port_time
));
gridRowAdapter.add(new GridButton(
LiveTvOption.LIVE_TV_SERIES_OPTION_ID,
getString(R.string.lbl_series),
R.drawable.tile_port_series_timer
));
gridRowAdapter.add(new GridButton(TvApp.LIVE_TV_RECORDINGS_OPTION_ID, TvApp.getApplication().getResources().getString(R.string.lbl_recorded_tv), R.drawable.tile_port_record));
if (mApplication.canManageRecordings()) {
gridRowAdapter.add(new GridButton(TvApp.LIVE_TV_SCHEDULE_OPTION_ID, TvApp.getApplication().getResources().getString(R.string.lbl_schedule), R.drawable.tile_port_time));
gridRowAdapter.add(new GridButton(TvApp.LIVE_TV_SERIES_OPTION_ID, TvApp.getApplication().getResources().getString(R.string.lbl_series), R.drawable.tile_port_series_timer));
}
mRowsAdapter.add(new ListRow(gridHeader, gridRowAdapter));

View File

@@ -0,0 +1,15 @@
package org.jellyfin.androidtv.browsing;
import android.os.Bundle;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.base.BaseActivity;
public class ByGenreActivity extends BaseActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_by_genre);
}
}

View File

@@ -0,0 +1,63 @@
package org.jellyfin.androidtv.browsing;
import android.os.Bundle;
import org.jellyfin.androidtv.TvApp;
import org.jellyfin.androidtv.querying.StdItemQuery;
import org.jellyfin.androidtv.util.DelayedMessage;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.apiclient.interaction.Response;
import org.jellyfin.apiclient.model.dto.BaseItemDto;
import org.jellyfin.apiclient.model.querying.ItemSortBy;
import org.jellyfin.apiclient.model.querying.ItemsByNameQuery;
import org.jellyfin.apiclient.model.querying.ItemsResult;
public class ByGenreFragment extends CustomViewFragment {
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
}
@Override
protected void setupQueries(final IRowLoader rowLoader) {
if (Utils.getSafeValue(mFolder.getChildCount(), 0) > 0) {
final DelayedMessage message = new DelayedMessage(getActivity());
//Get all genres for this folder
ItemsByNameQuery genres = new ItemsByNameQuery();
genres.setParentId(mFolder.getId());
genres.setSortBy(new String[]{ItemSortBy.SortName});
if (includeType != null) genres.setIncludeItemTypes(new String[]{includeType});
genres.setRecursive(true);
genres.setUserId(TvApp.getApplication().getCurrentUser().getId());
TvApp.getApplication().getApiClient().GetGenresAsync(genres, new Response<ItemsResult>() {
@Override
public void onResponse(ItemsResult response) {
for (BaseItemDto genre : response.getItems()) {
StdItemQuery genreQuery = new StdItemQuery();
genreQuery.setParentId(mFolder.getId());
genreQuery.setSortBy(new String[]{ItemSortBy.SortName});
if (includeType != null) genreQuery.setIncludeItemTypes(new String[]{includeType});
genreQuery.setGenres(new String[] {genre.getName()});
genreQuery.setRecursive(true);
mRows.add(new BrowseRowDef(genre.getName(), genreQuery, 40));
}
if (mRows.size() < 2) setHeadersState(HEADERS_DISABLED);
rowLoader.loadRows(mRows);
message.Cancel();
}
});
}
else {
setHeadersState(HEADERS_DISABLED);
}
}
}

View File

@@ -0,0 +1,15 @@
package org.jellyfin.androidtv.browsing;
import android.os.Bundle;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.base.BaseActivity;
public class ByLetterActivity extends BaseActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_by_letter);
}
}

View File

@@ -0,0 +1,49 @@
package org.jellyfin.androidtv.browsing;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.TvApp;
import org.jellyfin.androidtv.querying.StdItemQuery;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.apiclient.model.querying.ItemSortBy;
public class ByLetterFragment extends CustomViewFragment {
private static String letters = TvApp.getApplication().getResources().getString(R.string.byletter_letters);
@Override
protected void setupQueries(IRowLoader rowLoader) {
if (Utils.getSafeValue(mFolder.getChildCount(), 0) > 0) {
//First add a '#' item
StdItemQuery numbers = new StdItemQuery();
numbers.setParentId(mFolder.getId());
numbers.setSortBy(new String[]{ItemSortBy.SortName});
if (includeType != null) numbers.setIncludeItemTypes(new String[]{includeType});
numbers.setNameLessThan(letters.substring(0,1));
numbers.setRecursive(true);
mRows.add(new BrowseRowDef("#", numbers, 40));
//Then all the defined letters
for (Character letter : letters.toCharArray()) {
StdItemQuery letterQuery = new StdItemQuery();
letterQuery.setParentId(mFolder.getId());
letterQuery.setSortBy(new String[]{ItemSortBy.SortName});
if (includeType != null) letterQuery.setIncludeItemTypes(new String[]{includeType});
letterQuery.setNameStartsWith(letter.toString());
letterQuery.setRecursive(true);
mRows.add(new BrowseRowDef(letter.toString(), letterQuery, 40));
}
if (mRows.size() < 2) setHeadersState(HEADERS_DISABLED);
rowLoader.loadRows(mRows);
}
else {
setHeadersState(HEADERS_DISABLED);
}
}
}

View File

@@ -0,0 +1,15 @@
package org.jellyfin.androidtv.browsing;
import android.os.Bundle;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.base.BaseActivity;
public class CollectionActivity extends BaseActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_collection);
}
}

View File

@@ -0,0 +1,43 @@
package org.jellyfin.androidtv.browsing;
import android.os.Bundle;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.querying.StdItemQuery;
import org.jellyfin.androidtv.util.Utils;
public class CollectionFragment extends EnhancedBrowseFragment {
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
}
@Override
protected void setupQueries(IRowLoader rowLoader) {
if (Utils.getSafeValue(mFolder.getChildCount(), 0) > 0) {
StdItemQuery movies = new StdItemQuery();
movies.setParentId(mFolder.getId());
movies.setIncludeItemTypes(new String[]{"Movie"});
mRows.add(new BrowseRowDef(mApplication.getString(R.string.lbl_movies), movies, 100));
StdItemQuery series = new StdItemQuery();
series.setParentId(mFolder.getId());
series.setIncludeItemTypes(new String[]{"Series"});
mRows.add(new BrowseRowDef(mApplication.getString(R.string.lbl_tv_series), series, 100));
StdItemQuery others = new StdItemQuery();
others.setParentId(mFolder.getId());
others.setExcludeItemTypes(new String[]{"Movie", "Series"});
mRows.add(new BrowseRowDef(mApplication.getString(R.string.lbl_other), others, 100));
rowLoader.loadRows(mRows);
}
}
}

View File

@@ -0,0 +1,32 @@
package org.jellyfin.androidtv.browsing;
import androidx.leanback.widget.OnItemViewClickedListener;
import androidx.leanback.widget.Presenter;
import androidx.leanback.widget.Row;
import androidx.leanback.widget.RowPresenter;
import java.util.ArrayList;
import java.util.List;
/**
* Created by Eric on 4/15/2015.
*/
public class CompositeClickedListener implements OnItemViewClickedListener {
private List<OnItemViewClickedListener> registeredListeners = new ArrayList<>();
public void registerListener (OnItemViewClickedListener listener) {
registeredListeners.add(listener);
}
public void unRegisterListener (OnItemViewClickedListener listener) {
registeredListeners.remove(listener);
}
@Override
public void onItemClicked(Presenter.ViewHolder itemViewHolder, Object item, RowPresenter.ViewHolder rowViewHolder, Row row) {
for (OnItemViewClickedListener listener : registeredListeners) {
listener.onItemClicked(itemViewHolder, item, rowViewHolder, row);
}
}
}

View File

@@ -0,0 +1,29 @@
package org.jellyfin.androidtv.browsing;
import androidx.leanback.widget.OnItemViewSelectedListener;
import androidx.leanback.widget.Presenter;
import androidx.leanback.widget.Row;
import androidx.leanback.widget.RowPresenter;
import java.util.ArrayList;
import java.util.List;
public class CompositeSelectedListener implements OnItemViewSelectedListener {
private List<OnItemViewSelectedListener> registeredListeners = new ArrayList<>();
public void registerListener (OnItemViewSelectedListener listener) {
registeredListeners.add(listener);
}
public void unRegisterListener (OnItemViewSelectedListener listener) {
registeredListeners.remove(listener);
}
@Override
public void onItemSelected(Presenter.ViewHolder itemViewHolder, Object item, RowPresenter.ViewHolder rowViewHolder, Row row) {
for (OnItemViewSelectedListener listener : registeredListeners) {
listener.onItemSelected(itemViewHolder, item, rowViewHolder, row);
}
}
}

View File

@@ -0,0 +1,299 @@
package org.jellyfin.androidtv.browsing;
import androidx.fragment.app.Fragment;
import android.graphics.Bitmap;
import android.os.Bundle;
import android.os.Handler;
import androidx.leanback.app.BackgroundManager;
import androidx.leanback.app.RowsSupportFragment;
import androidx.leanback.widget.ArrayObjectAdapter;
import androidx.leanback.widget.HeaderItem;
import androidx.leanback.widget.ListRow;
import androidx.leanback.widget.OnItemViewClickedListener;
import androidx.leanback.widget.OnItemViewSelectedListener;
import androidx.leanback.widget.Presenter;
import androidx.leanback.widget.Row;
import androidx.leanback.widget.RowPresenter;
import android.util.DisplayMetrics;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import com.bumptech.glide.Glide;
import com.bumptech.glide.request.animation.GlideAnimation;
import com.bumptech.glide.request.target.SimpleTarget;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.TvApp;
import org.jellyfin.androidtv.base.BaseActivity;
import org.jellyfin.androidtv.itemhandling.BaseRowItem;
import org.jellyfin.androidtv.itemhandling.ItemLauncher;
import org.jellyfin.androidtv.itemhandling.ItemRowAdapter;
import org.jellyfin.androidtv.presentation.CardPresenter;
import org.jellyfin.androidtv.presentation.PositionableListRowPresenter;
import org.jellyfin.androidtv.querying.QueryType;
import org.jellyfin.androidtv.querying.ViewQuery;
import java.util.ArrayList;
import java.util.List;
import java.util.Timer;
import java.util.TimerTask;
public class CustomBrowseFragment extends Fragment implements IRowLoader {
private RowsSupportFragment mRowsFragment;
private static final int BACKGROUND_UPDATE_DELAY = 100;
protected String MainTitle;
protected boolean ShowBadge = true;
protected TvApp mApplication;
protected BaseActivity mActivity;
protected BaseRowItem mCurrentItem;
protected ListRow mCurrentRow;
protected CompositeClickedListener mClickedListener = new CompositeClickedListener();
protected CompositeSelectedListener mSelectedListener = new CompositeSelectedListener();
protected ArrayObjectAdapter mRowsAdapter;
private SimpleTarget<Bitmap> mBackgroundTarget;
private DisplayMetrics mMetrics;
private Timer mBackgroundTimer;
private final Handler mHandler = new Handler();
private String mBackgroundUrl;
protected ArrayList<BrowseRowDef> mRows = new ArrayList<>();
CardPresenter mCardPresenter;
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View root = inflater.inflate(R.layout.fragment_custom_browse, container, false);
// Inject the RowsSupportFragment in the results container
if (getChildFragmentManager().findFragmentById(R.id.rows_area) == null) {
mRowsFragment = new RowsSupportFragment();
getChildFragmentManager().beginTransaction()
.replace(R.id.rows_area, mRowsFragment).commit();
} else {
mRowsFragment = (RowsSupportFragment) getChildFragmentManager()
.findFragmentById(R.id.rows_area);
}
mRowsAdapter = new ArrayObjectAdapter(new PositionableListRowPresenter());
mRowsFragment.setAdapter(mRowsAdapter);
return root;
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
mApplication = TvApp.getApplication();
//mActivity = (BaseActivity) getActivity();
prepareBackgroundManager();
setupUIElements();
setupQueries(this);
setupEventListeners();
}
protected void setupQueries(IRowLoader rowLoader) {
rowLoader.loadRows(mRows);
}
@Override
public void onDestroy() {
super.onDestroy();
if (null != mBackgroundTimer) {
mBackgroundTimer.cancel();
}
}
@Override
public void onResume() {
super.onResume();
//Re-retrieve anything that needs it but delay slightly so we don't take away gui landing
if (mRowsAdapter != null) {
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
for (int i = 0; i < mRowsAdapter.size(); i++) {
if (mRowsAdapter.get(i) instanceof ListRow) {
if (((ListRow) mRowsAdapter.get(i)).getAdapter() instanceof ItemRowAdapter) {
((ItemRowAdapter) ((ListRow) mRowsAdapter.get(i)).getAdapter()).ReRetrieveIfNeeded();
}
}
}
}
},1500);
}
}
public void loadRows(List<BrowseRowDef> rows) {
mRowsAdapter = new ArrayObjectAdapter(new PositionableListRowPresenter());
mCardPresenter = new CardPresenter();
for (BrowseRowDef def : rows) {
HeaderItem header = new HeaderItem(def.getHeaderText());
ItemRowAdapter rowAdapter;
switch (def.getQueryType()) {
case NextUp:
rowAdapter = new ItemRowAdapter(def.getNextUpQuery(), true, mCardPresenter, mRowsAdapter);
break;
case Season:
rowAdapter = new ItemRowAdapter(def.getSeasonQuery(), mCardPresenter, mRowsAdapter);
break;
case Upcoming:
rowAdapter = new ItemRowAdapter(def.getUpcomingQuery(), mCardPresenter, mRowsAdapter);
break;
case Views:
rowAdapter = new ItemRowAdapter(new ViewQuery(), mCardPresenter, mRowsAdapter);
break;
case SimilarSeries:
rowAdapter = new ItemRowAdapter(def.getSimilarQuery(), QueryType.SimilarSeries, mCardPresenter, mRowsAdapter);
break;
case SimilarMovies:
rowAdapter = new ItemRowAdapter(def.getSimilarQuery(), QueryType.SimilarMovies, mCardPresenter, mRowsAdapter);
break;
case Persons:
rowAdapter = new ItemRowAdapter(def.getPersonsQuery(), def.getChunkSize(), mCardPresenter, mRowsAdapter);
break;
case LiveTvChannel:
rowAdapter = new ItemRowAdapter(def.getTvChannelQuery(), 40, mCardPresenter, mRowsAdapter);
break;
case LiveTvProgram:
rowAdapter = new ItemRowAdapter(def.getProgramQuery(), mCardPresenter, mRowsAdapter);
break;
case LiveTvRecording:
rowAdapter = new ItemRowAdapter(def.getRecordingQuery(), def.getChunkSize(), mCardPresenter, mRowsAdapter);
break;
default:
rowAdapter = new ItemRowAdapter(def.getQuery(), def.getChunkSize(), def.getPreferParentThumb(), def.isStaticHeight(), mCardPresenter, mRowsAdapter);
break;
}
rowAdapter.setReRetrieveTriggers(def.getChangeTriggers());
ListRow row = new ListRow(header, rowAdapter);
mRowsAdapter.add(row);
rowAdapter.setRow(row);
rowAdapter.Retrieve();
}
addAdditionalRows(mRowsAdapter);
mRowsFragment.setAdapter(mRowsAdapter);
}
protected void addAdditionalRows(ArrayObjectAdapter rowAdapter) {
}
private void prepareBackgroundManager() {
final BackgroundManager backgroundManager = BackgroundManager.getInstance(getActivity());
backgroundManager.attach(getActivity().getWindow());
mMetrics = new DisplayMetrics();
getActivity().getWindowManager().getDefaultDisplay().getMetrics(mMetrics);
mBackgroundTarget = new SimpleTarget<Bitmap>(mMetrics.widthPixels, mMetrics.heightPixels) {
@Override
public void onResourceReady(Bitmap resource, GlideAnimation<? super Bitmap> glideAnimation) {
backgroundManager.setBitmap(resource);
}
};
}
protected void setupUIElements() {
}
protected void setupEventListeners() {
mRowsFragment.setOnItemViewClickedListener(mClickedListener);
mClickedListener.registerListener(new ItemViewClickedListener());
mRowsFragment.setOnItemViewSelectedListener(mSelectedListener);
mSelectedListener.registerListener(new ItemViewSelectedListener());
}
private final class ItemViewClickedListener implements OnItemViewClickedListener {
@Override
public void onItemClicked(final Presenter.ViewHolder itemViewHolder, Object item,
RowPresenter.ViewHolder rowViewHolder, Row row) {
if (!(item instanceof BaseRowItem)) return;
ItemLauncher.launch((BaseRowItem) item, (ItemRowAdapter) ((ListRow)row).getAdapter(), ((BaseRowItem) item).getIndex(), getActivity());
}
}
private final class ItemViewSelectedListener implements OnItemViewSelectedListener {
@Override
public void onItemSelected(Presenter.ViewHolder itemViewHolder, Object item,
RowPresenter.ViewHolder rowViewHolder, Row row) {
if (!(item instanceof BaseRowItem)) {
//fill in default background
mBackgroundUrl = null;
startBackgroundTimer();
return;
}
BaseRowItem rowItem = (BaseRowItem) item;
mCurrentItem = rowItem;
mCurrentRow = (ListRow) row;
//mApplication.getLogger().Debug("Selected Item "+rowItem.getIndex() + " type: "+ (rowItem.getItemType().equals(BaseRowItem.ItemType.BaseItem) ? rowItem.getBaseItem().getType() : "other"));
ItemRowAdapter adapter = (ItemRowAdapter) ((ListRow)row).getAdapter();
adapter.loadMoreItemsIfNeeded(rowItem.getIndex());
mBackgroundUrl = rowItem.getBackdropImageUrl();
startBackgroundTimer();
}
}
protected void updateBackground(String url) {
if (url == null) {
clearBackground();
} else {
Glide.with(getActivity())
.load(url)
.asBitmap()
.override(mMetrics.widthPixels, mMetrics.heightPixels)
.centerCrop()
.into(mBackgroundTarget);
}
}
protected void clearBackground() {
BackgroundManager.getInstance(getActivity()).setDrawable(null);
}
private void startBackgroundTimer() {
if (null != mBackgroundTimer) {
mBackgroundTimer.cancel();
}
mBackgroundTimer = new Timer();
mBackgroundTimer.schedule(new UpdateBackgroundTask(), BACKGROUND_UPDATE_DELAY);
}
private class UpdateBackgroundTask extends TimerTask {
@Override
public void run() {
mHandler.post(new Runnable() {
@Override
public void run() {
updateBackground(mBackgroundUrl);
}
});
}
}
}

View File

@@ -0,0 +1,21 @@
package org.jellyfin.androidtv.browsing;
import android.os.Bundle;
import org.jellyfin.androidtv.TvApp;
/**
* Created by Eric on 1/18/2015.
*/
public class CustomViewFragment extends BrowseFolderFragment {
protected String includeType;
@Override
public void onActivityCreated(Bundle savedInstanceState) {
includeType = getActivity().getIntent().getStringExtra("IncludeType");
TvApp.getApplication().getLogger().Debug("Item type: "+includeType);
showViews = false;
super.onActivityCreated(savedInstanceState);
}
}

View File

@@ -0,0 +1,647 @@
package org.jellyfin.androidtv.browsing;
import androidx.fragment.app.Fragment;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.graphics.Typeface;
import android.os.Bundle;
import android.os.Handler;
import androidx.annotation.Nullable;
import androidx.leanback.app.BackgroundManager;
import androidx.leanback.app.RowsSupportFragment;
import androidx.leanback.widget.ArrayObjectAdapter;
import androidx.leanback.widget.ClassPresenterSelector;
import androidx.leanback.widget.HeaderItem;
import androidx.leanback.widget.ListRow;
import androidx.leanback.widget.OnItemViewClickedListener;
import androidx.leanback.widget.OnItemViewSelectedListener;
import androidx.leanback.widget.Presenter;
import androidx.leanback.widget.Row;
import androidx.leanback.widget.RowPresenter;
import android.util.DisplayMetrics;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import com.bumptech.glide.Glide;
import com.bumptech.glide.request.animation.GlideAnimation;
import com.bumptech.glide.request.target.SimpleTarget;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.TvApp;
import org.jellyfin.androidtv.base.BaseActivity;
import org.jellyfin.androidtv.base.CustomMessage;
import org.jellyfin.androidtv.base.IKeyListener;
import org.jellyfin.androidtv.base.IMessageListener;
import org.jellyfin.androidtv.details.ItemListActivity;
import org.jellyfin.androidtv.itemhandling.BaseRowItem;
import org.jellyfin.androidtv.itemhandling.ItemLauncher;
import org.jellyfin.androidtv.itemhandling.ItemRowAdapter;
import org.jellyfin.androidtv.playback.MediaManager;
import org.jellyfin.androidtv.presentation.CardPresenter;
import org.jellyfin.androidtv.presentation.GridButtonPresenter;
import org.jellyfin.androidtv.presentation.PositionableListRowPresenter;
import org.jellyfin.androidtv.querying.QueryType;
import org.jellyfin.androidtv.querying.ViewQuery;
import org.jellyfin.androidtv.ui.GridButton;
import org.jellyfin.androidtv.util.InfoLayoutHelper;
import org.jellyfin.androidtv.util.KeyProcessor;
import java.util.ArrayList;
import java.util.List;
import java.util.Timer;
import java.util.TimerTask;
import org.jellyfin.apiclient.interaction.EmptyResponse;
import org.jellyfin.apiclient.interaction.Response;
import org.jellyfin.apiclient.model.dto.BaseItemDto;
import org.jellyfin.apiclient.model.dto.BaseItemType;
import org.jellyfin.apiclient.model.entities.DisplayPreferences;
public class EnhancedBrowseFragment extends Fragment implements IRowLoader {
private static final int BACKGROUND_UPDATE_DELAY = 100;
Typeface roboto;
BaseActivity mActivity;
TvApp mApplication;
TextView mTitle;
LinearLayout mInfoRow;
TextView mSummary;
protected static final int BY_LETTER = 0;
protected static final int GENRES = 1;
protected static final int YEARS = 2;
protected static final int PERSONS = 3;
protected static final int SUGGESTED = 4;
protected static final int SEARCH = 5;
protected static final int GRID = 6;
protected static final int ALBUMS = 7;
protected static final int ARTISTS = 8;
public static final int FAVSONGS = 9;
protected static final int SCHEDULE = 10;
protected static final int SERIES = 11;
protected BaseItemDto mFolder;
protected String itemTypeString;
protected boolean showViews = true;
protected boolean justLoaded = true;
protected boolean ShowFanart = false;
protected BaseRowItem favSongsRowItem;
private SimpleTarget<Bitmap> mBackgroundTarget;
private DisplayMetrics mMetrics;
RowsSupportFragment mRowsFragment;
protected CompositeClickedListener mClickedListener = new CompositeClickedListener();
protected CompositeSelectedListener mSelectedListener = new CompositeSelectedListener();
protected ArrayObjectAdapter mRowsAdapter;
private Timer mBackgroundTimer;
private final Handler mHandler = new Handler();
private String mBackgroundUrl;
protected ArrayList<BrowseRowDef> mRows = new ArrayList<>();
CardPresenter mCardPresenter;
protected BaseRowItem mCurrentItem;
protected ListRow mCurrentRow;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
BaseItemDto item = new BaseItemDto();
item.setId(ItemListActivity.FAV_SONGS);
item.setBaseItemType(BaseItemType.Playlist);
item.setIsFolder(true);
favSongsRowItem = new BaseRowItem(0, item);
}
@Nullable
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View root = inflater.inflate(R.layout.enhanced_detail_browse, container, false);
mActivity = (BaseActivity) getActivity();
roboto = TvApp.getApplication().getDefaultFont();
mTitle = (TextView) root.findViewById(R.id.title);
mTitle.setTypeface(roboto);
mTitle.setShadowLayer(5, 5, 5, Color.BLACK);
mInfoRow = (LinearLayout) root.findViewById(R.id.infoRow);
mSummary = (TextView) root.findViewById(R.id.summary);
mSummary.setTypeface(roboto);
mSummary.setShadowLayer(5, 5, 5, Color.BLACK);
// Inject the RowsSupportFragment in the results container
if (getChildFragmentManager().findFragmentById(R.id.rowsFragment) == null) {
mRowsFragment = new RowsSupportFragment();
getChildFragmentManager().beginTransaction()
.replace(R.id.rowsFragment, mRowsFragment).commit();
} else {
mRowsFragment = (RowsSupportFragment) getChildFragmentManager()
.findFragmentById(R.id.rowsFragment);
}
mRowsAdapter = new ArrayObjectAdapter(new PositionableListRowPresenter());
mRowsFragment.setAdapter(mRowsAdapter);
return root;
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
mApplication = TvApp.getApplication();
prepareBackgroundManager();
setupViews();
setupUIElements();
setupQueries(this);
setupEventListeners();
}
protected void setupQueries(IRowLoader rowLoader) {
rowLoader.loadRows(mRows);
}
protected void setupViews() {
mFolder = TvApp.getApplication().getSerializer().DeserializeFromString(getActivity().getIntent().getStringExtra("Folder"),BaseItemDto.class);
if (mFolder == null) return;
if (mFolder.getCollectionType() != null) {
switch (mFolder.getCollectionType()) {
case "movies":
itemTypeString = "Movie";
break;
case "tvshows":
itemTypeString = "Series";
break;
case "music":
itemTypeString = "MusicAlbum";
break;
case "folders":
showViews = false;
break;
default:
showViews = false;
}
} else {
showViews = false;
}
}
@Override
public void onDestroy() {
super.onDestroy();
if (null != mBackgroundTimer) {
mBackgroundTimer.cancel();
}
}
@Override
public void onResume() {
super.onResume();
ShowFanart = mApplication.getPrefs().getBoolean("pref_show_backdrop", true);
//React to deletion
if (getActivity() != null && !getActivity().isFinishing() && mCurrentRow != null && mCurrentItem != null && mCurrentItem.getItemId() != null && mCurrentItem.getItemId().equals(TvApp.getApplication().getLastDeletedItemId())) {
((ItemRowAdapter)mCurrentRow.getAdapter()).remove(mCurrentItem);
TvApp.getApplication().setLastDeletedItemId(null);
}
if (!justLoaded) {
//Re-retrieve anything that needs it but delay slightly so we don't take away gui landing
if (mRowsAdapter != null) {
refreshCurrentItem();
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
if (mActivity.isFinishing()) return;
for (int i = 0; i < mRowsAdapter.size(); i++) {
if (mRowsAdapter.get(i) instanceof ListRow) {
if (((ListRow) mRowsAdapter.get(i)).getAdapter() instanceof ItemRowAdapter && !mActivity.isFinishing()) {
((ItemRowAdapter) ((ListRow) mRowsAdapter.get(i)).getAdapter()).ReRetrieveIfNeeded();
}
}
}
}
}, 1500);
}
} else {
justLoaded = false;
}
}
public void loadRows(List<BrowseRowDef> rows) {
mRowsAdapter = new ArrayObjectAdapter(new PositionableListRowPresenter());
mCardPresenter = new CardPresenter(false, 280);
ClassPresenterSelector ps = new ClassPresenterSelector();
ps.addClassPresenter(BaseRowItem.class, mCardPresenter);
ps.addClassPresenter(GridButton.class, new GridButtonPresenter(false, 310, 280));
for (BrowseRowDef def : rows) {
HeaderItem header = new HeaderItem(def.getHeaderText());
ItemRowAdapter rowAdapter;
switch (def.getQueryType()) {
case NextUp:
rowAdapter = new ItemRowAdapter(def.getNextUpQuery(), true, mCardPresenter, mRowsAdapter);
break;
case LatestItems:
rowAdapter = new ItemRowAdapter(def.getLatestItemsQuery(), true, mCardPresenter, mRowsAdapter);
break;
case Season:
rowAdapter = new ItemRowAdapter(def.getSeasonQuery(), mCardPresenter, mRowsAdapter);
break;
case Upcoming:
rowAdapter = new ItemRowAdapter(def.getUpcomingQuery(), mCardPresenter, mRowsAdapter);
break;
case Views:
rowAdapter = new ItemRowAdapter(new ViewQuery(), mCardPresenter, mRowsAdapter);
break;
case SimilarSeries:
rowAdapter = new ItemRowAdapter(def.getSimilarQuery(), QueryType.SimilarSeries, mCardPresenter, mRowsAdapter);
break;
case SimilarMovies:
rowAdapter = new ItemRowAdapter(def.getSimilarQuery(), QueryType.SimilarMovies, mCardPresenter, mRowsAdapter);
break;
case Persons:
rowAdapter = new ItemRowAdapter(def.getPersonsQuery(), def.getChunkSize(), mCardPresenter, mRowsAdapter);
break;
case LiveTvChannel:
rowAdapter = new ItemRowAdapter(def.getTvChannelQuery(), 40, mCardPresenter, mRowsAdapter);
break;
case LiveTvProgram:
rowAdapter = new ItemRowAdapter(def.getProgramQuery(), mCardPresenter, mRowsAdapter);
break;
case LiveTvRecording:
rowAdapter = new ItemRowAdapter(def.getRecordingQuery(), def.getChunkSize(), mCardPresenter, mRowsAdapter);
break;
case LiveTvRecordingGroup:
rowAdapter = new ItemRowAdapter(def.getRecordingGroupQuery(), mCardPresenter, mRowsAdapter);
break;
case Premieres:
rowAdapter = new ItemRowAdapter(def.getQuery(), def.getChunkSize(), def.getPreferParentThumb(), def.isStaticHeight(), mCardPresenter, mRowsAdapter, def.getQueryType());
break;
case SeriesTimer:
rowAdapter = new ItemRowAdapter(def.getSeriesTimerQuery(), mCardPresenter, mRowsAdapter);
break;
default:
rowAdapter = new ItemRowAdapter(def.getQuery(), def.getChunkSize(), def.getPreferParentThumb(), def.isStaticHeight(), ps, mRowsAdapter, def.getQueryType());
break;
}
rowAdapter.setReRetrieveTriggers(def.getChangeTriggers());
ListRow row = new ListRow(header, rowAdapter);
mRowsAdapter.add(row);
rowAdapter.setRow(row);
rowAdapter.Retrieve();
}
addAdditionalRows(mRowsAdapter);
mRowsFragment.setAdapter(mRowsAdapter);
}
protected void addAdditionalRows(ArrayObjectAdapter rowAdapter) {
if (showViews) {
HeaderItem gridHeader = new HeaderItem(rowAdapter.size(), mApplication.getString(R.string.lbl_views));
GridButtonPresenter mGridPresenter = new GridButtonPresenter();
ArrayObjectAdapter gridRowAdapter = new ArrayObjectAdapter(mGridPresenter);
switch (itemTypeString) {
case "Movie":
gridRowAdapter.add(new GridButton(SUGGESTED, mApplication.getString(R.string.lbl_suggested), R.drawable.tile_suggestions));
addStandardViewButtons(gridRowAdapter);
break;
case "MusicAlbum":
gridRowAdapter.add(new GridButton(ALBUMS, TvApp.getApplication().getString(R.string.lbl_albums), R.drawable.tile_audio));
gridRowAdapter.add(new GridButton(ARTISTS, TvApp.getApplication().getString(R.string.lbl_artists), R.drawable.tile_artists));
gridRowAdapter.add(new GridButton(GENRES, mApplication.getString(R.string.lbl_genres), R.drawable.tile_genres));
gridRowAdapter.add(new GridButton(SEARCH, mApplication.getString(R.string.lbl_search), R.drawable.tile_search));
break;
default:
addStandardViewButtons(gridRowAdapter);
break;
}
rowAdapter.add(new ListRow(gridHeader, gridRowAdapter));
}
}
protected void addStandardViewButtons(ArrayObjectAdapter gridRowAdapter) {
gridRowAdapter.add(new GridButton(GRID, TvApp.getApplication().getString(R.string.lbl_all_items), R.drawable.tile_port_grid));
gridRowAdapter.add(new GridButton(BY_LETTER, mApplication.getString(R.string.lbl_by_letter), R.drawable.tile_letters));
gridRowAdapter.add(new GridButton(GENRES, mApplication.getString(R.string.lbl_genres), R.drawable.tile_genres));
//gridRowAdapter.add(new GridButton(PERSONS, mApplication.getString(R.string.lbl_performers), R.drawable.tile_actors));
gridRowAdapter.add(new GridButton(SEARCH, mApplication.getString(R.string.lbl_search), R.drawable.tile_search));
}
protected void setupUIElements() {
}
protected void setupEventListeners() {
mRowsFragment.setOnItemViewClickedListener(mClickedListener);
mClickedListener.registerListener(new ItemViewClickedListener());
if (showViews) mClickedListener.registerListener(new SpecialViewClickedListener());
mRowsFragment.setOnItemViewSelectedListener(mSelectedListener);
mSelectedListener.registerListener(new ItemViewSelectedListener());
if (mActivity != null) {
mActivity.registerKeyListener(new IKeyListener() {
@Override
public boolean onKeyUp(int key, KeyEvent event) {
return KeyProcessor.HandleKey(key, mCurrentItem, mActivity);
}
});
mActivity.registerMessageListener(new IMessageListener() {
@Override
public void onMessageReceived(CustomMessage message) {
switch (message) {
case RefreshCurrentItem:
refreshCurrentItem();
break;
}
}
});
}
}
private void refreshCurrentItem() {
if (mCurrentItem != null && mCurrentItem.getBaseItemType() != BaseItemType.Photo && mCurrentItem.getBaseItemType() != BaseItemType.MusicArtist
&& mCurrentItem.getBaseItemType() != BaseItemType.MusicAlbum && mCurrentItem.getBaseItemType() != BaseItemType.Playlist) {
mCurrentItem.refresh(new EmptyResponse() {
@Override
public void onResponse() {
ItemRowAdapter adapter = (ItemRowAdapter) ((ListRow) mCurrentRow).getAdapter();
adapter.notifyArrayItemRangeChanged(adapter.indexOf(mCurrentItem), 1);
}
});
}
}
private final class SpecialViewClickedListener implements OnItemViewClickedListener {
@Override
public void onItemClicked(Presenter.ViewHolder itemViewHolder, Object item,
RowPresenter.ViewHolder rowViewHolder, Row row) {
if (item instanceof GridButton) {
switch (((GridButton) item).getId()) {
case GRID:
TvApp.getApplication().getDisplayPrefsAsync(mFolder.getDisplayPreferencesId(), new Response<DisplayPreferences>() {
@Override
public void onResponse(DisplayPreferences response) {
Intent folderIntent = new Intent(getActivity(), GenericGridActivity.class);
folderIntent.putExtra("Folder", TvApp.getApplication().getSerializer().SerializeToString(mFolder));
getActivity().startActivity(folderIntent);
}
});
break;
case ALBUMS:
mFolder.setDisplayPreferencesId(mFolder.getId()+"AL");
TvApp.getApplication().getDisplayPrefsAsync(mFolder.getDisplayPreferencesId(), new Response<DisplayPreferences>() {
@Override
public void onResponse(DisplayPreferences response) {
Intent folderIntent = new Intent(getActivity(), GenericGridActivity.class);
folderIntent.putExtra("Folder", TvApp.getApplication().getSerializer().SerializeToString(mFolder));
folderIntent.putExtra("IncludeType", "MusicAlbum");
getActivity().startActivity(folderIntent);
}
});
break;
case ARTISTS:
mFolder.setDisplayPreferencesId(mFolder.getId()+"AR");
TvApp.getApplication().getDisplayPrefsAsync(mFolder.getDisplayPreferencesId(), new Response<DisplayPreferences>() {
@Override
public void onResponse(DisplayPreferences response) {
Intent folderIntent = new Intent(getActivity(), GenericGridActivity.class);
folderIntent.putExtra("Folder", TvApp.getApplication().getSerializer().SerializeToString(mFolder));
folderIntent.putExtra("IncludeType", "AlbumArtist");
getActivity().startActivity(folderIntent);
}
});
break;
case BY_LETTER:
Intent intent = new Intent(getActivity(), ByLetterActivity.class);
intent.putExtra("Folder", TvApp.getApplication().getSerializer().SerializeToString(mFolder));
intent.putExtra("IncludeType", itemTypeString);
getActivity().startActivity(intent);
break;
case GENRES:
Intent genreIntent = new Intent(getActivity(), ByGenreActivity.class);
genreIntent.putExtra("Folder", TvApp.getApplication().getSerializer().SerializeToString(mFolder));
genreIntent.putExtra("IncludeType", itemTypeString);
getActivity().startActivity(genreIntent);
break;
case SUGGESTED:
Intent suggIntent = new Intent(getActivity(), SuggestedMoviesActivity.class);
suggIntent.putExtra("Folder", TvApp.getApplication().getSerializer().SerializeToString(mFolder));
suggIntent.putExtra("IncludeType", itemTypeString);
getActivity().startActivity(suggIntent);
break;
case PERSONS:
Intent personIntent = new Intent(getActivity(), BrowsePersonsActivity.class);
personIntent.putExtra("Folder", TvApp.getApplication().getSerializer().SerializeToString(mFolder));
personIntent.putExtra("IncludeType", itemTypeString);
getActivity().startActivity(personIntent);
break;
case SEARCH:
TvApp.getApplication().showSearch(getActivity(), "MusicAlbum".equals(itemTypeString));
break;
case FAVSONGS:
Intent favIntent = new Intent(getActivity(), ItemListActivity.class);
favIntent.putExtra("ItemId", ItemListActivity.FAV_SONGS);
favIntent.putExtra("ParentId", mFolder.getId());
getActivity().startActivity(favIntent);
break;
case SERIES:
case TvApp.LIVE_TV_SERIES_OPTION_ID:
Intent seriesIntent = new Intent(mActivity, UserViewActivity.class);
BaseItemDto seriesTimers = new BaseItemDto();
seriesTimers.setId("SERIESTIMERS");
seriesTimers.setCollectionType("SeriesTimers");
seriesTimers.setName(mActivity.getString(R.string.lbl_series_recordings));
seriesIntent.putExtra("Folder", TvApp.getApplication().getSerializer().SerializeToString(seriesTimers));
getActivity().startActivity(seriesIntent);
break;
case SCHEDULE:
case TvApp.LIVE_TV_SCHEDULE_OPTION_ID:
Intent schedIntent = new Intent(mActivity, BrowseScheduleActivity.class);
getActivity().startActivity(schedIntent);
break;
case TvApp.LIVE_TV_RECORDINGS_OPTION_ID:
Intent recordings = new Intent(mActivity, BrowseRecordingsActivity.class);
BaseItemDto folder = new BaseItemDto();
folder.setId("");
folder.setName(TvApp.getApplication().getResources().getString(R.string.lbl_recorded_tv));
recordings.putExtra("Folder", TvApp.getApplication().getSerializer().SerializeToString(folder));
mActivity.startActivity(recordings);
break;
default:
Toast.makeText(getActivity(), item.toString() + mApplication.getString(R.string.msg_not_implemented), Toast.LENGTH_SHORT)
.show();
break;
}
}
}
}
private final class ItemViewClickedListener implements OnItemViewClickedListener {
@Override
public void onItemClicked(final Presenter.ViewHolder itemViewHolder, Object item,
RowPresenter.ViewHolder rowViewHolder, Row row) {
if (!(item instanceof BaseRowItem)) return;
MediaManager.setCurrentMediaTitle(row.getHeaderItem().getName());
ItemLauncher.launch((BaseRowItem) item, (ItemRowAdapter) ((ListRow)row).getAdapter(), ((BaseRowItem)item).getIndex(), getActivity());
}
}
private final class ItemViewSelectedListener implements OnItemViewSelectedListener {
@Override
public void onItemSelected(Presenter.ViewHolder itemViewHolder, Object item,
RowPresenter.ViewHolder rowViewHolder, Row row) {
mHandler.removeCallbacks(updateContentTask);
if (item instanceof GridButton && ((GridButton)item).getId() == FAVSONGS) {
//set to specialized item
mCurrentItem = favSongsRowItem;
}
if (!(item instanceof BaseRowItem)) {
mTitle.setText(mFolder != null ? mFolder.getName() : "");
mInfoRow.removeAllViews();
mSummary.setText("");
//fill in default background
mBackgroundUrl = null;
startBackgroundTimer();
return;
}
BaseRowItem rowItem = (BaseRowItem) item;
mCurrentItem = rowItem;
mCurrentRow = (ListRow) row;
mTitle.setText(mCurrentItem.getName());
mInfoRow.removeAllViews();
mSummary.setText("");
mHandler.postDelayed(updateContentTask, 500);
//mApplication.getLogger().Debug("Selected Item "+rowItem.getIndex() + " type: "+ (rowItem.getItemType().equals(BaseRowItem.ItemType.BaseItem) ? rowItem.getBaseItem().getType() : "other"));
ItemRowAdapter adapter = (ItemRowAdapter) ((ListRow)row).getAdapter();
adapter.loadMoreItemsIfNeeded(rowItem.getIndex());
if (ShowFanart) {
mBackgroundUrl = rowItem.getBackdropImageUrl();
startBackgroundTimer();
}
}
}
protected Runnable updateContentTask = new Runnable() {
@Override
public void run() {
if (mCurrentItem == null) return;
mTitle.setText(mCurrentItem.getName());
mSummary.setText(mCurrentItem.getSummary());
InfoLayoutHelper.addInfoRow(mActivity, mCurrentItem, mInfoRow, true, true);
}
};
private void prepareBackgroundManager() {
final BackgroundManager backgroundManager = BackgroundManager.getInstance(getActivity());
backgroundManager.attach(getActivity().getWindow());
mMetrics = new DisplayMetrics();
getActivity().getWindowManager().getDefaultDisplay().getMetrics(mMetrics);
mBackgroundTarget = new SimpleTarget<Bitmap>(mMetrics.widthPixels, mMetrics.heightPixels) {
@Override
public void onResourceReady(Bitmap resource, GlideAnimation<? super Bitmap> glideAnimation) {
backgroundManager.setBitmap(resource);
TvApp.getApplication().setCurrentBackground(resource);
}
};
}
protected void updateBackground(String url) {
if (url == null) {
clearBackground();
} else {
Glide.with(getActivity())
.load(url)
.asBitmap()
.override(mMetrics.widthPixels, mMetrics.heightPixels)
.centerCrop()
.into(mBackgroundTarget);
}
}
protected void clearBackground() {
BackgroundManager.getInstance(getActivity()).setDrawable(null);
}
private void startBackgroundTimer() {
if (null != mBackgroundTimer) {
mBackgroundTimer.cancel();
}
mBackgroundTimer = new Timer();
mBackgroundTimer.schedule(new UpdateBackgroundTask(), BACKGROUND_UPDATE_DELAY);
}
private class UpdateBackgroundTask extends TimerTask {
@Override
public void run() {
mHandler.post(new Runnable() {
@Override
public void run() {
updateBackground(mBackgroundUrl);
}
});
}
}
}

View File

@@ -0,0 +1,15 @@
package org.jellyfin.androidtv.browsing;
import android.os.Bundle;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.base.BaseActivity;
public class GenericFolderActivity extends BaseActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_generic_folder);
}
}

View File

@@ -0,0 +1,83 @@
package org.jellyfin.androidtv.browsing;
import android.os.Bundle;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.TvApp;
import org.jellyfin.androidtv.querying.StdItemQuery;
import java.util.Arrays;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.apiclient.model.dto.BaseItemType;
import org.jellyfin.apiclient.model.entities.SortOrder;
import org.jellyfin.apiclient.model.livetv.RecordingQuery;
import org.jellyfin.apiclient.model.querying.ItemFields;
import org.jellyfin.apiclient.model.querying.ItemFilter;
import org.jellyfin.apiclient.model.querying.ItemSortBy;
public class GenericFolderFragment extends EnhancedBrowseFragment {
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
}
private static BaseItemType[] showSpecialViewTypes = new BaseItemType[] {BaseItemType.CollectionFolder, BaseItemType.Folder, BaseItemType.UserView, BaseItemType.ChannelFolderItem };
@Override
protected void setupQueries(IRowLoader rowLoader) {
if (mFolder.getBaseItemType() == BaseItemType.RecordingGroup){
RecordingQuery query = new RecordingQuery();
query.setUserId(TvApp.getApplication().getCurrentUser().getId());
query.setGroupId(mFolder.getId());
query.setFields(new ItemFields[] {ItemFields.PrimaryImageAspectRatio});
mRows.add(new BrowseRowDef(mApplication.getResources().getString(R.string.lbl_all_items), query));
rowLoader.loadRows(mRows);
} else {
if (Utils.getSafeValue(mFolder.getChildCount(), 0) > 0 ||
mFolder.getBaseItemType() == BaseItemType.Channel ||
mFolder.getBaseItemType() == BaseItemType.ChannelFolderItem ||
mFolder.getBaseItemType() == BaseItemType.UserView ||
mFolder.getBaseItemType() == BaseItemType.CollectionFolder) {
boolean showSpecialViews = Arrays.asList(showSpecialViewTypes).contains(mFolder.getBaseItemType()) && !"channels".equals(mFolder.getCollectionType());
if (showSpecialViews) {
if (mFolder.getBaseItemType() != BaseItemType.ChannelFolderItem) {
StdItemQuery resume = new StdItemQuery();
resume.setParentId(mFolder.getId());
resume.setLimit(50);
resume.setFilters(new ItemFilter[]{ItemFilter.IsResumable});
resume.setSortBy(new String[]{ItemSortBy.DatePlayed});
resume.setSortOrder(SortOrder.Descending);
mRows.add(new BrowseRowDef(mApplication.getString(R.string.lbl_continue_watching), resume, 0));
}
StdItemQuery latest = new StdItemQuery();
latest.setParentId(mFolder.getId());
latest.setLimit(50);
latest.setFilters(new ItemFilter[]{ItemFilter.IsUnplayed});
latest.setSortBy(new String[]{ItemSortBy.DateCreated});
latest.setSortOrder(SortOrder.Descending);
mRows.add(new BrowseRowDef(mApplication.getString(R.string.lbl_latest_additions), latest, 0));
}
StdItemQuery byName = new StdItemQuery();
byName.setParentId(mFolder.getId());
mRows.add(new BrowseRowDef(mApplication.getString(R.string.lbl_by_name), byName, 100));
rowLoader.loadRows(mRows);
}
}
}
}

View File

@@ -0,0 +1,15 @@
package org.jellyfin.androidtv.browsing;
import android.os.Bundle;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.base.BaseActivity;
public class GenericGridActivity extends BaseActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_browse_grid);
}
}

View File

@@ -0,0 +1,5 @@
package org.jellyfin.androidtv.browsing;
public interface IGridLoader {
void loadGrid(BrowseRowDef rowDef);
}

View File

@@ -0,0 +1,7 @@
package org.jellyfin.androidtv.browsing;
import java.util.List;
public interface IRowLoader {
void loadRows(List<BrowseRowDef> rows);
}

View File

@@ -0,0 +1,12 @@
package org.jellyfin.androidtv.browsing
import android.os.Bundle
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.base.BaseActivity
class MainActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_home)
}
}

View File

@@ -0,0 +1,510 @@
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing permissions and limitations under
* the License.
*/
package org.jellyfin.androidtv.browsing;
import android.graphics.Bitmap;
import android.os.Bundle;
import android.os.Handler;
import androidx.leanback.app.BackgroundManager;
import androidx.leanback.app.BrowseSupportFragment;
import androidx.leanback.widget.ArrayObjectAdapter;
import androidx.leanback.widget.HeaderItem;
import androidx.leanback.widget.ListRow;
import androidx.leanback.widget.OnItemViewClickedListener;
import androidx.leanback.widget.OnItemViewSelectedListener;
import androidx.leanback.widget.Presenter;
import androidx.leanback.widget.Row;
import androidx.leanback.widget.RowPresenter;
import android.util.DisplayMetrics;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import com.bumptech.glide.Glide;
import com.bumptech.glide.request.animation.GlideAnimation;
import com.bumptech.glide.request.target.SimpleTarget;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.TvApp;
import org.jellyfin.androidtv.base.BaseActivity;
import org.jellyfin.androidtv.base.CustomMessage;
import org.jellyfin.androidtv.base.IKeyListener;
import org.jellyfin.androidtv.base.IMessageListener;
import org.jellyfin.androidtv.itemhandling.AudioQueueItem;
import org.jellyfin.androidtv.itemhandling.BaseRowItem;
import org.jellyfin.androidtv.itemhandling.ItemLauncher;
import org.jellyfin.androidtv.itemhandling.ItemRowAdapter;
import org.jellyfin.androidtv.presentation.CardPresenter;
import org.jellyfin.androidtv.presentation.PositionableListRowPresenter;
import org.jellyfin.androidtv.querying.QueryType;
import org.jellyfin.androidtv.querying.ViewQuery;
import org.jellyfin.androidtv.ui.ClockUserView;
import org.jellyfin.androidtv.ui.ItemPanel;
import org.jellyfin.androidtv.util.KeyProcessor;
import org.jellyfin.androidtv.util.Utils;
import java.util.ArrayList;
import java.util.List;
import java.util.Timer;
import java.util.TimerTask;
import org.jellyfin.apiclient.interaction.EmptyResponse;
import org.jellyfin.apiclient.model.dto.BaseItemType;
public class StdBrowseFragment extends BrowseSupportFragment implements IRowLoader {
private static final String TAG = "StdBrowseFragment";
private static final int BACKGROUND_UPDATE_DELAY = 100;
protected String MainTitle;
protected boolean ShowBadge = true;
protected boolean ShowInfoPanel = true;
protected boolean ShowFanart = false;
protected TvApp mApplication;
protected BaseActivity mActivity;
protected BaseRowItem mCurrentItem;
protected ListRow mCurrentRow;
protected CompositeClickedListener mClickedListener = new CompositeClickedListener();
protected CompositeSelectedListener mSelectedListener = new CompositeSelectedListener();
protected ArrayObjectAdapter mRowsAdapter;
private SimpleTarget<Bitmap> mBackgroundTarget;
private DisplayMetrics mMetrics;
private Timer mBackgroundTimer;
private final Handler mHandler = new Handler();
private String mBackgroundUrl;
protected ArrayList<BrowseRowDef> mRows = new ArrayList<>();
protected CardPresenter mCardPresenter;
protected boolean justLoaded = true;
private ItemPanel mItemPanel;
private Animation fadeInPanel;
private Animation fadeOutPanel;
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
mApplication = TvApp.getApplication();
if (getActivity() instanceof BaseActivity) mActivity = (BaseActivity)getActivity();
prepareBackgroundManager();
setupUIElements();
setupQueries(this);
setupEventListeners();
}
protected void setupQueries(IRowLoader rowLoader) {
rowLoader.loadRows(mRows);
}
@Override
public void onDestroy() {
super.onDestroy();
if (null != mBackgroundTimer) {
mBackgroundTimer.cancel();
}
}
@Override
public void onPause() {
super.onPause();
}
@Override
public void onResume() {
super.onResume();
// set info panel option
ShowInfoPanel = mApplication.getPrefs().getBoolean("pref_enable_info_panel", true);
ShowFanart = mApplication.getPrefs().getBoolean("pref_show_backdrop", true);
//React to deletion
if (getActivity() != null && !getActivity().isFinishing() && mCurrentRow != null && mCurrentItem != null && mCurrentItem.getItemId() != null && mCurrentItem.getItemId().equals(TvApp.getApplication().getLastDeletedItemId())) {
((ItemRowAdapter)mCurrentRow.getAdapter()).remove(mCurrentItem);
TvApp.getApplication().setLastDeletedItemId(null);
}
if (!justLoaded) {
//Re-retrieve anything that needs it but delay slightly so we don't take away gui landing
if (mRowsAdapter != null) {
refreshCurrentItem();
refreshRows();
}
} else {
justLoaded = false;
}
}
protected void refreshRows() {
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
if (mActivity.isFinishing()) return;
for (int i = 0; i < mRowsAdapter.size(); i++) {
if (mRowsAdapter.get(i) instanceof ListRow) {
if (((ListRow) mRowsAdapter.get(i)).getAdapter() instanceof ItemRowAdapter && !mActivity.isFinishing()) {
((ItemRowAdapter) ((ListRow) mRowsAdapter.get(i)).getAdapter()).ReRetrieveIfNeeded();
}
}
}
}
},1500);
}
public void loadRows(List<BrowseRowDef> rows) {
mRowsAdapter = new ArrayObjectAdapter(new PositionableListRowPresenter());
mCardPresenter = new CardPresenter();
for (BrowseRowDef def : rows) {
HeaderItem header = new HeaderItem(def.getHeaderText());
ItemRowAdapter rowAdapter;
switch (def.getQueryType()) {
case NextUp:
rowAdapter = new ItemRowAdapter(def.getNextUpQuery(), true, mCardPresenter, mRowsAdapter);
break;
case LatestItems:
rowAdapter = new ItemRowAdapter(def.getLatestItemsQuery(), true, mCardPresenter, mRowsAdapter);
break;
case Season:
rowAdapter = new ItemRowAdapter(def.getSeasonQuery(), mCardPresenter, mRowsAdapter);
break;
case Upcoming:
rowAdapter = new ItemRowAdapter(def.getUpcomingQuery(), mCardPresenter, mRowsAdapter);
break;
case Views:
rowAdapter = new ItemRowAdapter(new ViewQuery(), mCardPresenter, mRowsAdapter);
break;
case SimilarSeries:
rowAdapter = new ItemRowAdapter(def.getSimilarQuery(), QueryType.SimilarSeries, mCardPresenter, mRowsAdapter);
break;
case SimilarMovies:
rowAdapter = new ItemRowAdapter(def.getSimilarQuery(), QueryType.SimilarMovies, mCardPresenter, mRowsAdapter);
break;
case Persons:
rowAdapter = new ItemRowAdapter(def.getPersonsQuery(), def.getChunkSize(), mCardPresenter, mRowsAdapter);
break;
case LiveTvChannel:
rowAdapter = new ItemRowAdapter(def.getTvChannelQuery(), 40, mCardPresenter, mRowsAdapter);
break;
case LiveTvProgram:
rowAdapter = new ItemRowAdapter(def.getProgramQuery(), mCardPresenter, mRowsAdapter);
break;
case LiveTvRecording:
rowAdapter = new ItemRowAdapter(def.getRecordingQuery(), def.getChunkSize(), mCardPresenter, mRowsAdapter);
break;
case LiveTvRecordingGroup:
rowAdapter = new ItemRowAdapter(def.getRecordingGroupQuery(), mCardPresenter, mRowsAdapter);
break;
default:
rowAdapter = new ItemRowAdapter(def.getQuery(), def.getChunkSize(), def.getPreferParentThumb(), def.isStaticHeight(), mCardPresenter, mRowsAdapter, def.getQueryType());
break;
}
rowAdapter.setReRetrieveTriggers(def.getChangeTriggers());
ListRow row = new ListRow(header, rowAdapter);
mRowsAdapter.add(row);
rowAdapter.setRow(row);
rowAdapter.Retrieve();
}
addAdditionalRows(mRowsAdapter);
setAdapter(mRowsAdapter);
}
protected void addAdditionalRows(ArrayObjectAdapter rowAdapter) {
}
private void prepareBackgroundManager() {
final BackgroundManager backgroundManager = BackgroundManager.getInstance(getActivity());
backgroundManager.attach(getActivity().getWindow());
mMetrics = new DisplayMetrics();
getActivity().getWindowManager().getDefaultDisplay().getMetrics(mMetrics);
mBackgroundTarget = new SimpleTarget<Bitmap>(mMetrics.widthPixels, mMetrics.heightPixels) {
@Override
public void onResourceReady(Bitmap resource, GlideAnimation<? super Bitmap> glideAnimation) {
backgroundManager.setBitmap(resource);
mApplication.setCurrentBackground(resource);
}
};
}
protected void setupUIElements() {
if (ShowBadge) setBadgeDrawable(getActivity().getResources().getDrawable(R.drawable.app_logo_transparent));
setTitle(MainTitle); // Badge, when set, takes precedent over title
setHeadersState(HEADERS_ENABLED);
setHeadersTransitionOnBackEnabled(true);
// move the badge/title to the left to make way for our clock/user bug
ImageView badge = (ImageView) getActivity().findViewById(R.id.title_badge);
if (badge != null) {
FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) badge.getLayoutParams();
lp.rightMargin = Utils.convertDpToPixel(getActivity(), 120);
lp.width = Utils.convertDpToPixel(getActivity(), 250);
badge.setLayoutParams(lp);
}
TextView title = (TextView) getActivity().findViewById(R.id.title_text);
if (title != null) {
FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) title.getLayoutParams();
lp.rightMargin = Utils.convertDpToPixel(getActivity(), 120);
lp.width = Utils.convertDpToPixel(getActivity(), 250);
title.setLayoutParams(lp);
}
ViewGroup root = (ViewGroup) getActivity().findViewById(android.R.id.content);
// add item panel
mItemPanel = new ItemPanel(getActivity());
FrameLayout.LayoutParams panelLayout = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, Utils.convertDpToPixel(TvApp.getApplication(), 145));
panelLayout.gravity = Gravity.BOTTOM;
panelLayout.bottomMargin = -10;
mItemPanel.setLayoutParams(panelLayout);
root.addView(mItemPanel);
mItemPanel.setVisibility(View.INVISIBLE);
// and add the clock element
FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
ClockUserView clock = new ClockUserView(getActivity());
layoutParams.gravity = Gravity.TOP | Gravity.RIGHT;
layoutParams.rightMargin = Utils.convertDpToPixel(getActivity(), 40);
layoutParams.topMargin = Utils.convertDpToPixel(getActivity(), 20);
clock.setLayoutParams(layoutParams);
root.addView(clock);
// load item panel animation
fadeInPanel = AnimationUtils.loadAnimation(mActivity, R.anim.abc_fade_in);
fadeInPanel.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
}
@Override
public void onAnimationEnd(Animation animation) {
mItemPanel.setVisibility(View.VISIBLE);
}
@Override
public void onAnimationRepeat(Animation animation) {
}
});
fadeOutPanel = AnimationUtils.loadAnimation(mActivity, R.anim.abc_fade_out);
fadeOutPanel.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
}
@Override
public void onAnimationEnd(Animation animation) {
mItemPanel.setVisibility(View.INVISIBLE);
}
@Override
public void onAnimationRepeat(Animation animation) {
}
});
}
private Runnable showItemPanel = new Runnable() {
@Override
public void run() {
if (mCurrentItem != null && !mCurrentItem.isFolder() && (mCurrentItem.getRuntimeTicks() > 0 || (mCurrentItem.getSummary() != null && mCurrentItem.getSummary().length() > 0))) {
mItemPanel.setItem(mCurrentItem);
mItemPanel.startAnimation(fadeInPanel);
mHandler.removeCallbacks(hideItemPanel);
mHandler.postDelayed(hideItemPanel, 20000);
} else {
mItemPanel.setVisibility(View.INVISIBLE);
}
}
};
private Runnable hideItemPanel = new Runnable() {
@Override
public void run() {
mItemPanel.startAnimation(fadeOutPanel);
}
};
protected void setupEventListeners() {
setOnSearchClickedListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
TvApp.getApplication().showSearch(getActivity(), false);
}
});
setOnItemViewClickedListener(mClickedListener);
mClickedListener.registerListener(new ItemViewClickedListener());
setOnItemViewSelectedListener(mSelectedListener);
mSelectedListener.registerListener(new ItemViewSelectedListener());
if (mActivity != null) {
mActivity.registerKeyListener(new IKeyListener() {
@Override
public boolean onKeyUp(int key, KeyEvent event) {
return KeyProcessor.HandleKey(key, mCurrentItem, mActivity);
}
});
mActivity.registerMessageListener(new IMessageListener() {
@Override
public void onMessageReceived(CustomMessage message) {
switch (message) {
case RefreshCurrentItem:
refreshCurrentItem();
break;
}
}
});
}
}
private void refreshCurrentItem() {
if (mCurrentItem != null && mCurrentItem.getBaseItemType() != BaseItemType.UserView && mCurrentItem.getBaseItemType() != BaseItemType.CollectionFolder) {
TvApp.getApplication().getLogger().Debug("Refresh item "+mCurrentItem.getFullName());
mCurrentItem.refresh(new EmptyResponse() {
@Override
public void onResponse() {
ItemRowAdapter adapter = (ItemRowAdapter) ((ListRow)mCurrentRow).getAdapter();
adapter.notifyArrayItemRangeChanged(adapter.indexOf(mCurrentItem), 1);
}
});
}
}
private final class ItemViewClickedListener implements OnItemViewClickedListener {
@Override
public void onItemClicked(final Presenter.ViewHolder itemViewHolder, Object item,
RowPresenter.ViewHolder rowViewHolder, Row row) {
if (!(item instanceof BaseRowItem)) return;
ItemLauncher.launch((BaseRowItem) item, (ItemRowAdapter) ((ListRow)row).getAdapter(), ((BaseRowItem)item).getIndex(), getActivity());
}
}
private final class ItemViewSelectedListener implements OnItemViewSelectedListener {
@Override
public void onItemSelected(Presenter.ViewHolder itemViewHolder, Object item,
RowPresenter.ViewHolder rowViewHolder, Row row) {
if (ShowInfoPanel) {
// cancel any delayed showing and hide item panel
mHandler.removeCallbacks(showItemPanel);
mItemPanel.setVisibility(View.INVISIBLE);
}
if (!(item instanceof BaseRowItem)) {
mCurrentItem = null;
mHandler.removeCallbacks(hideItemPanel);
//fill in default background
mBackgroundUrl = null;
startBackgroundTimer();
return;
} else {
mCurrentItem = (BaseRowItem)item;
if (!isShowingHeaders() && ShowInfoPanel && !(item instanceof AudioQueueItem)) {
// delay show the item panel
mHandler.postDelayed(showItemPanel, 1000);
}
}
mCurrentRow = (ListRow) row;
BaseRowItem rowItem = (BaseRowItem) item;
if (((ListRow) row).getAdapter() instanceof ItemRowAdapter) {
//mApplication.getLogger().Debug("Selected Item "+rowItem.getIndex() + " type: "+ (rowItem.getItemType().equals(BaseRowItem.ItemType.BaseItem) ? rowItem.getBaseItem().getType() : "other"));
ItemRowAdapter adapter = (ItemRowAdapter) ((ListRow) row).getAdapter();
adapter.loadMoreItemsIfNeeded(rowItem.getIndex());
}
if (ShowFanart) {
mBackgroundUrl = rowItem.getBackdropImageUrl();
startBackgroundTimer();
}
}
}
protected void updateBackground(String url) {
if (url == null) {
clearBackground();
} else {
Glide.with(getActivity())
.load(url)
.asBitmap()
.override(mMetrics.widthPixels, mMetrics.heightPixels)
.centerCrop()
.into(mBackgroundTarget);
}
}
protected void clearBackground() {
BackgroundManager.getInstance(getActivity()).setDrawable(null);
}
private void startBackgroundTimer() {
if (null != mBackgroundTimer) {
mBackgroundTimer.cancel();
}
mBackgroundTimer = new Timer();
mBackgroundTimer.schedule(new UpdateBackgroundTask(), BACKGROUND_UPDATE_DELAY);
}
private class UpdateBackgroundTask extends TimerTask {
@Override
public void run() {
mHandler.post(new Runnable() {
@Override
public void run() {
updateBackground(mBackgroundUrl);
}
});
}
}
}

View File

@@ -0,0 +1,703 @@
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing permissions and limitations under
* the License.
*/
package org.jellyfin.androidtv.browsing;
import android.app.Activity;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.os.Bundle;
import android.os.Handler;
import androidx.leanback.app.BackgroundManager;
import androidx.leanback.widget.OnItemViewClickedListener;
import androidx.leanback.widget.OnItemViewSelectedListener;
import androidx.leanback.widget.Presenter;
import androidx.leanback.widget.Row;
import androidx.leanback.widget.RowPresenter;
import android.util.DisplayMetrics;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.MenuItem;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.PopupMenu;
import android.widget.PopupWindow;
import com.bumptech.glide.Glide;
import com.bumptech.glide.request.animation.GlideAnimation;
import com.bumptech.glide.request.target.SimpleTarget;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.TvApp;
import org.jellyfin.androidtv.base.BaseActivity;
import org.jellyfin.androidtv.base.CustomMessage;
import org.jellyfin.androidtv.base.IKeyListener;
import org.jellyfin.androidtv.base.IMessageListener;
import org.jellyfin.androidtv.itemhandling.BaseRowItem;
import org.jellyfin.androidtv.itemhandling.ItemLauncher;
import org.jellyfin.androidtv.itemhandling.ItemRowAdapter;
import org.jellyfin.androidtv.model.FilterOptions;
import org.jellyfin.androidtv.model.ImageType;
import org.jellyfin.androidtv.model.PosterSize;
import org.jellyfin.androidtv.playback.MediaManager;
import org.jellyfin.androidtv.presentation.CardPresenter;
import org.jellyfin.androidtv.presentation.HorizontalGridPresenter;
import org.jellyfin.androidtv.querying.QueryType;
import org.jellyfin.androidtv.querying.ViewQuery;
import org.jellyfin.androidtv.ui.CharSelectedListener;
import org.jellyfin.androidtv.ui.DisplayPrefsPopup;
import org.jellyfin.androidtv.ui.HorizontalGridFragment;
import org.jellyfin.androidtv.ui.ImageButton;
import org.jellyfin.androidtv.ui.JumpList;
import org.jellyfin.androidtv.util.KeyProcessor;
import org.jellyfin.androidtv.util.Utils;
import java.util.HashMap;
import java.util.Timer;
import java.util.TimerTask;
import org.jellyfin.apiclient.interaction.EmptyResponse;
import org.jellyfin.apiclient.interaction.Response;
import org.jellyfin.apiclient.model.dto.BaseItemDto;
import org.jellyfin.apiclient.model.dto.BaseItemType;
import org.jellyfin.apiclient.model.entities.DisplayPreferences;
public class StdGridFragment extends HorizontalGridFragment implements IGridLoader {
private static final String TAG = "StdGridFragment";
private static final int BACKGROUND_UPDATE_DELAY = 100;
protected String MainTitle;
protected TvApp mApplication;
protected BaseActivity mActivity;
protected BaseRowItem mCurrentItem;
protected CompositeClickedListener mClickedListener = new CompositeClickedListener();
protected CompositeSelectedListener mSelectedListener = new CompositeSelectedListener();
protected ItemRowAdapter mGridAdapter;
private SimpleTarget<Bitmap> mBackgroundTarget;
private DisplayMetrics mMetrics;
private Timer mBackgroundTimer;
private final Handler mHandler = new Handler();
private String mBackgroundUrl;
protected BrowseRowDef mRowDef;
CardPresenter mCardPresenter;
protected boolean justLoaded = true;
protected boolean ShowFanart = false;
protected String mPosterSizeSetting = PosterSize.AUTO;
protected String mImageType = ImageType.DEFAULT;
protected boolean determiningPosterSize = false;
protected String mParentId;
protected BaseItemDto mFolder;
protected DisplayPreferences mDisplayPrefs;
private int mCardHeight = SMALL_CARD;
protected boolean mAllowViewSelection = true;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mFolder = TvApp.getApplication().getSerializer().DeserializeFromString(getActivity().getIntent().getStringExtra("Folder"), BaseItemDto.class);
mParentId = mFolder.getId();
MainTitle = mFolder.getName();
mDisplayPrefs = TvApp.getApplication().getCachedDisplayPrefs(mFolder.getDisplayPreferencesId()); //These should have already been loaded
mPosterSizeSetting = mDisplayPrefs.getCustomPrefs().get("PosterSize");
mImageType = mDisplayPrefs.getCustomPrefs().get("ImageType");
if (mImageType == null) mImageType = ImageType.DEFAULT;
if (mPosterSizeSetting == null) mPosterSizeSetting = PosterSize.AUTO;
mCardHeight = getCardHeight(mPosterSizeSetting);
setupUIElements();
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
mApplication = TvApp.getApplication();
if (getActivity() instanceof BaseActivity) mActivity = (BaseActivity)getActivity();
prepareBackgroundManager();
setupQueries(this);
addTools();
setupEventListeners();
}
protected void setupQueries(IGridLoader gridLoader) {
}
@Override
public void onDestroy() {
super.onDestroy();
if (null != mBackgroundTimer) {
mBackgroundTimer.cancel();
}
}
@Override
public void onPause() {
super.onPause();
}
@Override
public void onResume() {
super.onResume();
ShowFanart = mApplication.getPrefs().getBoolean("pref_show_backdrop", true);
if (!justLoaded) {
//Re-retrieve anything that needs it but delay slightly so we don't take away gui landing
if (mGridAdapter != null) {
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
if (mActivity.isFinishing()) return;
if (mGridAdapter != null && mGridAdapter.size() > 0) {
if (!mGridAdapter.ReRetrieveIfNeeded()) refreshCurrentItem();
}
}
},500);
}
} else {
justLoaded = false;
}
}
public void setCardHeight(int height) {
mCardHeight = height;
}
public int getCardHeight() {
return mCardHeight;
}
protected void buildAdapter(BrowseRowDef rowDef) {
mCardPresenter = new CardPresenter(false, mImageType, mCardHeight);
switch (mRowDef.getQueryType()) {
case NextUp:
mGridAdapter = new ItemRowAdapter(mRowDef.getNextUpQuery(), true, mCardPresenter, null);
break;
case Season:
mGridAdapter = new ItemRowAdapter(mRowDef.getSeasonQuery(), mCardPresenter, null);
break;
case Upcoming:
mGridAdapter = new ItemRowAdapter(mRowDef.getUpcomingQuery(), mCardPresenter, null);
break;
case Views:
mGridAdapter = new ItemRowAdapter(new ViewQuery(), mCardPresenter, null);
break;
case SimilarSeries:
mGridAdapter = new ItemRowAdapter(mRowDef.getSimilarQuery(), QueryType.SimilarSeries, mCardPresenter, null);
break;
case SimilarMovies:
mGridAdapter = new ItemRowAdapter(mRowDef.getSimilarQuery(), QueryType.SimilarMovies, mCardPresenter, null);
break;
case Persons:
mGridAdapter = new ItemRowAdapter(mRowDef.getPersonsQuery(), mRowDef.getChunkSize(), mCardPresenter, null);
break;
case LiveTvChannel:
mGridAdapter = new ItemRowAdapter(mRowDef.getTvChannelQuery(), 40, mCardPresenter, null);
break;
case LiveTvProgram:
mGridAdapter = new ItemRowAdapter(mRowDef.getProgramQuery(), mCardPresenter, null);
break;
case LiveTvRecording:
mGridAdapter = new ItemRowAdapter(mRowDef.getRecordingQuery(), mRowDef.getChunkSize(), mCardPresenter, null);
break;
case LiveTvRecordingGroup:
mGridAdapter = new ItemRowAdapter(mRowDef.getRecordingGroupQuery(), mCardPresenter, null);
break;
case AlbumArtists:
mGridAdapter = new ItemRowAdapter(mRowDef.getArtistsQuery(), mRowDef.getChunkSize(), mCardPresenter, null);
break;
default:
mGridAdapter = new ItemRowAdapter(mRowDef.getQuery(), mRowDef.getChunkSize(), mRowDef.getPreferParentThumb(), mRowDef.isStaticHeight(), mCardPresenter, null);
break;
}
FilterOptions filters = new FilterOptions();
filters.setFavoriteOnly(Boolean.parseBoolean(mDisplayPrefs.getCustomPrefs().get("FavoriteOnly")));
filters.setUnwatchedOnly(Boolean.parseBoolean(mDisplayPrefs.getCustomPrefs().get("UnwatchedOnly")));
setupRetrieveListeners();
mGridAdapter.setFilters(filters);
setAdapter(mGridAdapter);
}
public void loadGrid(final BrowseRowDef rowDef) {
determiningPosterSize = true;
buildAdapter(rowDef);
if (mPosterSizeSetting.equals(PosterSize.AUTO)) {
mGridAdapter.GetResultSizeAsync(new Response<Integer>() {
@Override
public void onResponse(Integer response) {
int autoHeight = getAutoCardHeight(response);
if (autoHeight != mCardHeight) {
mCardHeight = autoHeight;
setNumberOfRows();
createGrid();
TvApp.getApplication().getLogger().Debug("Auto card height is "+mCardHeight);
buildAdapter(rowDef);
}
mGridAdapter.setSortBy(getSortOption(mDisplayPrefs.getSortBy()));
mGridAdapter.Retrieve();
determiningPosterSize = false;
}
});
} else {
mGridAdapter.setSortBy(getSortOption(mDisplayPrefs.getSortBy()));
mGridAdapter.Retrieve();
determiningPosterSize = false;
}
}
protected int getCardHeight(String heightSetting) {
switch (heightSetting) {
case PosterSize.MED:
return mImageType.equals(ImageType.BANNER) ? MED_BANNER : MED_CARD;
case PosterSize.LARGE:
return mImageType.equals(ImageType.BANNER) ? LARGE_BANNER : LARGE_CARD;
default:
return mImageType.equals(ImageType.BANNER) ? SMALL_BANNER : SMALL_CARD;
}
}
protected int getAutoCardHeight(Integer size) {
TvApp.getApplication().getLogger().Debug("Result size for auto card height is " + size);
if (size > 35)
return getCardHeight(PosterSize.SMALL);
else if (size > 10)
return getCardHeight(PosterSize.MED);
else
return getCardHeight(PosterSize.LARGE);
}
private void prepareBackgroundManager() {
final BackgroundManager backgroundManager = BackgroundManager.getInstance(getActivity());
backgroundManager.attach(getActivity().getWindow());
mMetrics = new DisplayMetrics();
getActivity().getWindowManager().getDefaultDisplay().getMetrics(mMetrics);
mBackgroundTarget = new SimpleTarget<Bitmap>(mMetrics.widthPixels, mMetrics.heightPixels) {
@Override
public void onResourceReady(Bitmap resource, GlideAnimation<? super Bitmap> glideAnimation) {
backgroundManager.setBitmap(resource);
mApplication.setCurrentBackground(resource);
}
};
}
protected void setupUIElements() {
HorizontalGridPresenter gridPresenter = new HorizontalGridPresenter();
setGridPresenter(gridPresenter);
setNumberOfRows();
mJumplistPopup = new JumplistPopup(getActivity());
}
protected void setNumberOfRows() {
// calculate number of rows based on card height
getGridPresenter().setNumberOfRows(getGridHeight() / getCardHeight());
}
protected ImageButton mUnwatchedButton;
protected ImageButton mFavoriteButton;
protected ImageButton mLetterButton;
protected DisplayPrefsPopup mDisplayPrefsPopup;
protected void updateDisplayPrefs() {
if (mDisplayPrefs.getCustomPrefs() == null)
mDisplayPrefs.setCustomPrefs(new HashMap<String, String>());
mDisplayPrefs.getCustomPrefs().put("UnwatchedOnly", mGridAdapter.getFilters().isUnwatchedOnly() ? "true" : "false");
mDisplayPrefs.getCustomPrefs().put("FavoriteOnly", mGridAdapter.getFilters().isFavoriteOnly() ? "true" : "false");
mDisplayPrefs.setSortBy(mGridAdapter.getSortBy());
mDisplayPrefs.setSortOrder(getSortOption(mGridAdapter.getSortBy()).order);
TvApp.getApplication().updateDisplayPrefs(mDisplayPrefs);
}
protected void addTools() {
//Add tools
LinearLayout toolBar = getToolBar();
int size = Utils.convertDpToPixel(getActivity(), 24);
mDisplayPrefsPopup = new DisplayPrefsPopup(getActivity(), mGridDock, mAllowViewSelection, new Response<Boolean>() {
@Override
public void onResponse(Boolean response) {
TvApp.getApplication().updateDisplayPrefs(mDisplayPrefs);
if (response)
{
mImageType = mDisplayPrefs.getCustomPrefs().get("ImageType");
mPosterSizeSetting = mDisplayPrefs.getCustomPrefs().get("PosterSize");
mCardHeight = getCardHeight(mPosterSizeSetting);
setNumberOfRows();
createGrid();
loadGrid(mRowDef);
}
}
});
toolBar.addView(new ImageButton(getActivity(), R.drawable.ic_sort, size, new View.OnClickListener() {
@Override
public void onClick(View v) {
//Create sort menu
PopupMenu sortMenu = Utils.createPopupMenu(getActivity(), getToolBar(), Gravity.RIGHT);
for (Integer key : sortOptions.keySet()) {
SortOption option = sortOptions.get(key);
if (option == null) option = sortOptions.get(0);
MenuItem item = sortMenu.getMenu().add(0, key, key, option.name);
if (option.value.equals(mDisplayPrefs.getSortBy())) item.setChecked(true);
}
sortMenu.getMenu().setGroupCheckable(0, true, true);
sortMenu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
@Override
public boolean onMenuItemClick(MenuItem item) {
mGridAdapter.setSortBy(sortOptions.get(item.getItemId()));
mGridAdapter.Retrieve();
item.setChecked(true);
updateDisplayPrefs();
return true;
}
});
sortMenu.show();
}
}));
if (mRowDef.getQueryType() == QueryType.Items) {
mUnwatchedButton = new ImageButton(getActivity(), mGridAdapter.getFilters().isUnwatchedOnly() ? R.drawable.ic_unwatch_red : R.drawable.ic_unwatch, size, new View.OnClickListener() {
@Override
public void onClick(View v) {
FilterOptions filters = mGridAdapter.getFilters();
if (filters == null) filters = new FilterOptions();
filters.setUnwatchedOnly(!filters.isUnwatchedOnly());
updateDisplayPrefs();
mGridAdapter.setFilters(filters);
if (mPosterSizeSetting.equals(PosterSize.AUTO)) {
loadGrid(mRowDef);
} else {
mGridAdapter.Retrieve();
}
mUnwatchedButton.setImageResource(filters.isUnwatchedOnly() ? R.drawable.ic_unwatch_red : R.drawable.ic_unwatch);
}
});
toolBar.addView(mUnwatchedButton);
}
mFavoriteButton =new ImageButton(getActivity(), mGridAdapter.getFilters().isFavoriteOnly() ? R.drawable.ic_heart_red : R.drawable.ic_heart, size, new View.OnClickListener() {
@Override
public void onClick(View v) {
FilterOptions filters = mGridAdapter.getFilters();
if (filters == null) filters = new FilterOptions();
filters.setFavoriteOnly(!filters.isFavoriteOnly());
mGridAdapter.setFilters(filters);
updateDisplayPrefs();
if (mPosterSizeSetting.equals(PosterSize.AUTO)) {
loadGrid(mRowDef);
} else {
mGridAdapter.Retrieve();
}
mFavoriteButton.setImageResource(filters.isFavoriteOnly() ? R.drawable.ic_heart_red : R.drawable.ic_heart);
}
});
toolBar.addView(mFavoriteButton);
mLetterButton = new ImageButton(getActivity(), R.drawable.ic_jump_letter, size, new View.OnClickListener() {
@Override
public void onClick(View v) {
//Open letter jump popup
mJumplistPopup.show();
}
});
toolBar.addView(mLetterButton);
toolBar.addView(new ImageButton(getActivity(), R.drawable.ic_search, size, new View.OnClickListener() {
@Override
public void onClick(View v) {
TvApp.getApplication().showSearch(getActivity(), "music".equals(mFolder.getCollectionType()) || mFolder.getBaseItemType() == BaseItemType.MusicAlbum || mFolder.getBaseItemType() == BaseItemType.MusicArtist);
}
}));
toolBar.addView(new ImageButton(getActivity(), R.drawable.ic_settings, size, new View.OnClickListener() {
@Override
public void onClick(View v) {
mDisplayPrefsPopup.show(mDisplayPrefs, mFolder.getCollectionType());
}
}));
}
private JumplistPopup mJumplistPopup;
class JumplistPopup {
final int WIDTH = Utils.convertDpToPixel(TvApp.getApplication(), 900);
final int HEIGHT = Utils.convertDpToPixel(TvApp.getApplication(), 55);
PopupWindow mPopup;
Activity mActivity;
JumpList mJumplist;
JumplistPopup(Activity activity) {
mActivity = activity;
LayoutInflater inflater = (LayoutInflater) activity.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
View layout = inflater.inflate(R.layout.empty_popup, null);
mPopup = new PopupWindow(layout, WIDTH, HEIGHT);
mPopup.setFocusable(true);
mPopup.setOutsideTouchable(true);
mPopup.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); // necessary for popup to dismiss
mPopup.setAnimationStyle(R.style.PopupSlideInTop);
mJumplist = new JumpList(activity, new CharSelectedListener() {
@Override
public void onCharSelected(String ch) {
mGridAdapter.setStartLetter(ch);
loadGrid(mRowDef);
dismiss();
}
});
mJumplist.setGravity(Gravity.CENTER_HORIZONTAL);
FrameLayout root = (FrameLayout) layout.findViewById(R.id.root);
root.addView(mJumplist);
}
public boolean isShowing() {
return (mPopup != null && mPopup.isShowing());
}
public void show() {
mPopup.showAtLocation(mGridDock, Gravity.TOP, mGridDock.getLeft(), mGridDock.getTop());
mJumplist.setFocus(mGridAdapter.getStartLetter());
}
public void dismiss() {
if (mPopup != null && mPopup.isShowing()) {
mPopup.dismiss();
}
}
}
protected void setupEventListeners() {
setOnItemViewClickedListener(mClickedListener);
mClickedListener.registerListener(new ItemViewClickedListener());
setOnItemViewSelectedListener(mSelectedListener);
mSelectedListener.registerListener(new ItemViewSelectedListener());
if (mActivity != null) {
mActivity.registerKeyListener(new IKeyListener() {
@Override
public boolean onKeyUp(int key, KeyEvent event) {
if (key == KeyEvent.KEYCODE_MEDIA_PLAY || key == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) {
MediaManager.setCurrentMediaAdapter(mGridAdapter);
MediaManager.setCurrentMediaPosition(mCurrentItem.getIndex());
MediaManager.setCurrentMediaTitle(mFolder.getName());
}
return KeyProcessor.HandleKey(key, mCurrentItem, mActivity);
}
});
mActivity.registerMessageListener(new IMessageListener() {
@Override
public void onMessageReceived(CustomMessage message) {
switch (message) {
case RefreshCurrentItem:
refreshCurrentItem();
break;
}
}
});
}
}
protected void setupRetrieveListeners() {
mGridAdapter.setRetrieveStartedListener(new EmptyResponse() {
@Override
public void onResponse() {
showSpinner();
}
});
mGridAdapter.setRetrieveFinishedListener(new EmptyResponse() {
@Override
public void onResponse() {
hideSpinner();
setStatusText(mFolder.getName());
updateCounter(mGridAdapter.getTotalItems() > 0 ? 1 : 0);
mLetterButton.setVisibility("SortName".equals(mGridAdapter.getSortBy()) ? View.VISIBLE : View.GONE);
setItem(null);
if (mGridAdapter.getTotalItems() == 0) {
mToolBar.requestFocus();
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
setTitle(mFolder.getName());
}
}, 500);
} else focusGrid();
}
});
}
private void refreshCurrentItem() {
if (MediaManager.getCurrentMediaPosition() >= 0) {
mCurrentItem = MediaManager.getCurrentMediaItem();
getGridPresenter().setPosition(MediaManager.getCurrentMediaPosition());
MediaManager.setCurrentMediaPosition(-1); // re-set so it doesn't mess with parent views
}
if (mCurrentItem != null && mCurrentItem.getBaseItemType() != BaseItemType.Photo && mCurrentItem.getBaseItemType() != BaseItemType.PhotoAlbum
&& mCurrentItem.getBaseItemType() != BaseItemType.MusicArtist && mCurrentItem.getBaseItemType() != BaseItemType.MusicAlbum) {
TvApp.getApplication().getLogger().Debug("Refresh item "+mCurrentItem.getFullName());
mCurrentItem.refresh(new EmptyResponse() {
@Override
public void onResponse() {
mGridAdapter.notifyArrayItemRangeChanged(mGridAdapter.indexOf(mCurrentItem), 1);
//Now - if filtered make sure we still pass
if (mGridAdapter.getFilters() != null) {
if ((mGridAdapter.getFilters().isFavoriteOnly() && !mCurrentItem.isFavorite()) || (mGridAdapter.getFilters().isUnwatchedOnly() && mCurrentItem.isPlayed())) {
//if we are about to remove last item, throw focus to toolbar so framework doesn't crash
if (mGridAdapter.size() == 1) mToolBar.requestFocus();
mGridAdapter.remove(mCurrentItem);
mGridAdapter.setTotalItems(mGridAdapter.getTotalItems() - 1);
updateCounter(mCurrentItem.getIndex());
}
}
}
});
}
}
private final class ItemViewClickedListener implements OnItemViewClickedListener {
@Override
public void onItemClicked(final Presenter.ViewHolder itemViewHolder, Object item,
RowPresenter.ViewHolder rowViewHolder, Row row) {
if (!(item instanceof BaseRowItem)) return;
ItemLauncher.launch((BaseRowItem) item, mGridAdapter, ((BaseRowItem)item).getIndex(), getActivity());
}
}
private final Runnable mDelayedSetItem = new Runnable() {
@Override
public void run() {
if (ShowFanart) {
mBackgroundUrl = mCurrentItem.getBackdropImageUrl();
startBackgroundTimer();
}
setItem(mCurrentItem);
}
};
private final class ItemViewSelectedListener implements OnItemViewSelectedListener {
@Override
public void onItemSelected(Presenter.ViewHolder itemViewHolder, Object item,
RowPresenter.ViewHolder rowViewHolder, Row row) {
mHandler.removeCallbacks(mDelayedSetItem);
if (!(item instanceof BaseRowItem)) {
mCurrentItem = null;
setTitle(MainTitle);
//fill in default background
mBackgroundUrl = null;
startBackgroundTimer();
} else {
mCurrentItem = (BaseRowItem)item;
mTitleView.setText(mCurrentItem.getName());
mInfoRow.removeAllViews();
mHandler.postDelayed(mDelayedSetItem, 400);
if (!determiningPosterSize) mGridAdapter.loadMoreItemsIfNeeded(mCurrentItem.getIndex());
}
}
}
protected void updateBackground(String url) {
if (url == null) {
clearBackground();
} else {
Glide.with(getActivity())
.load(url)
.asBitmap()
.override(mMetrics.widthPixels, mMetrics.heightPixels)
.centerCrop()
.into(mBackgroundTarget);
}
}
protected void clearBackground() {
BackgroundManager.getInstance(getActivity()).setDrawable(null);
}
private void startBackgroundTimer() {
if (null != mBackgroundTimer) {
mBackgroundTimer.cancel();
}
mBackgroundTimer = new Timer();
mBackgroundTimer.schedule(new UpdateBackgroundTask(), BACKGROUND_UPDATE_DELAY);
}
private class UpdateBackgroundTask extends TimerTask {
@Override
public void run() {
mHandler.post(new Runnable() {
@Override
public void run() {
updateBackground(mBackgroundUrl);
}
});
}
}
}

View File

@@ -0,0 +1,15 @@
package org.jellyfin.androidtv.browsing;
import android.os.Bundle;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.base.BaseActivity;
public class SuggestedMoviesActivity extends BaseActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_suggested_movie);
}
}

View File

@@ -0,0 +1,59 @@
package org.jellyfin.androidtv.browsing;
import android.os.Bundle;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.TvApp;
import org.jellyfin.androidtv.querying.QueryType;
import org.jellyfin.androidtv.querying.StdItemQuery;
import org.jellyfin.apiclient.interaction.Response;
import org.jellyfin.apiclient.model.dto.BaseItemDto;
import org.jellyfin.apiclient.model.entities.SortOrder;
import org.jellyfin.apiclient.model.querying.ItemFields;
import org.jellyfin.apiclient.model.querying.ItemSortBy;
import org.jellyfin.apiclient.model.querying.ItemsResult;
import org.jellyfin.apiclient.model.querying.SimilarItemsQuery;
/**
* Created by Eric on 12/4/2014.
*/
public class SuggestedMoviesFragment extends EnhancedBrowseFragment {
@Override
public void onActivityCreated(Bundle savedInstanceState) {
showViews = false;
super.onActivityCreated(savedInstanceState);
}
@Override
protected void setupQueries(final IRowLoader rowLoader) {
StdItemQuery lastPlayed = new StdItemQuery();
lastPlayed.setParentId(mFolder.getId());
lastPlayed.setIncludeItemTypes(new String[]{"Movie"});
lastPlayed.setUserId(TvApp.getApplication().getCurrentUser().getId());
lastPlayed.setSortOrder(SortOrder.Descending);
lastPlayed.setSortBy(new String[]{ItemSortBy.DatePlayed});
lastPlayed.setLimit(8);
lastPlayed.setRecursive(true);
TvApp.getApplication().getApiClient().GetItemsAsync(lastPlayed, new Response<ItemsResult>() {
@Override
public void onResponse(ItemsResult response) {
for (BaseItemDto item : response.getItems()) {
SimilarItemsQuery similar = new SimilarItemsQuery();
similar.setId(item.getId());
similar.setFields(new ItemFields[] {ItemFields.PrimaryImageAspectRatio, ItemFields.Overview});
similar.setLimit(7);
mRows.add(new BrowseRowDef(mApplication.getString(R.string.lbl_because_you_watched)+item.getName(), similar, QueryType.SimilarMovies));
}
rowLoader.loadRows(mRows);
}
});
}
}

View File

@@ -0,0 +1,15 @@
package org.jellyfin.androidtv.browsing;
import android.os.Bundle;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.base.BaseActivity;
public class UserViewActivity extends BaseActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_user_view);
}
}

View File

@@ -0,0 +1,126 @@
package org.jellyfin.androidtv.channels
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms
import androidx.tvprovider.media.tv.WatchNextProgram
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.TvApp
import org.jellyfin.androidtv.startup.StartupActivity
import org.jellyfin.androidtv.util.apiclient.getNextUpEpisodes
import org.jellyfin.apiclient.model.drawing.ImageFormat
import org.jellyfin.apiclient.model.dto.BaseItemDto
import org.jellyfin.apiclient.model.dto.ImageOptions
import org.jellyfin.apiclient.model.querying.ItemFields
import org.jellyfin.apiclient.model.querying.NextUpQuery
/**
* Manages channels on the android tv home screen
*
* More info: https://developer.android.com/training/tv/discovery/recommendations-channel
*/
class ChannelManager {
private companion object {
/**
* Amount of ticks found in a millisecond, used for calculation
*/
private const val TICKS_IN_MILLISECOND = 10000
}
private val application = TvApp.getApplication()
/**
* Check if the app can use Leanback features and is API level 26 or higher
*/
private val isSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
&& application.packageManager.hasSystemFeature("android.software.leanback")
/**
* Update all channels for the currently authenticated user
*/
fun update() {
// Don't do anything if not supported
if (!isSupported) return
// Launch in separate coroutine scope
GlobalScope.launch {
updateWatchNext()
}
}
/**
* Updates the "watch next" row with new and unfinished episodes
* does not include movies, music or other types of media
*/
private suspend fun updateWatchNext() = withContext(Dispatchers.Default) {
// Delete current items
application.contentResolver.delete(WatchNextPrograms.CONTENT_URI, null, null)
// Get user or return if no user is found (not authenticated)
val user = application.currentUser ?: return@withContext
// Get new items
val response = application.apiClient.getNextUpEpisodes(NextUpQuery().apply {
userId = user.id
imageTypeLimit = 1
limit = 10
fields = arrayOf(ItemFields.DateCreated)
})
// Add new items
response?.items?.forEach { item ->
application.contentResolver.insert(
WatchNextPrograms.CONTENT_URI,
getBaseItemAsWatchNextProgram(item).toContentValues()
)
}
}
/**
* Convert [BaseItemDto] to [WatchNextProgram]
*
* Assumes the item type is "episode"
*/
private fun getBaseItemAsWatchNextProgram(item: BaseItemDto) = WatchNextProgram.Builder().apply {
setInternalProviderId(item.id)
setType(WatchNextPrograms.TYPE_TV_EPISODE)
setTitle("${item.seriesName} - ${item.name}")
// Poster image
setPosterArtAspectRatio(WatchNextPrograms.ASPECT_RATIO_16_9)
setPosterArtUri(Uri.parse(application.apiClient.GetImageUrl(item, ImageOptions().apply {
format = ImageFormat.Png
height = 288
width = 512
})))
// Use date created or fallback to current time if unavailable
setLastEngagementTimeUtcMillis(item.dateCreated?.time ?: System.currentTimeMillis())
when {
// User has started playing the episode
item.canResume -> {
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE)
setLastPlaybackPositionMillis((item.resumePositionTicks / TICKS_IN_MILLISECOND).toInt())
setDurationMillis((item.runTimeTicks / TICKS_IN_MILLISECOND).toInt())
}
// First episode of the season
item.indexNumber == 0 -> {
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEW)
}
// Default
else -> {
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT)
}
}
// Set intent to open the episode
setIntent(Intent(application, StartupActivity::class.java).apply {
putExtra("ItemId", item.id)
})
}.build()
}

View File

@@ -1,12 +0,0 @@
package org.jellyfin.androidtv.constant
enum class ChangeTriggerType {
LibraryUpdated,
MoviePlayback,
TvPlayback,
GuideNeedsLoad,
MusicPlayback,
Always,
VideoQueueChange,
FavoriteUpdate,
}

View File

@@ -1,83 +0,0 @@
package org.jellyfin.androidtv.constant
object Codec {
object Container {
@Suppress("ObjectPropertyName", "ObjectPropertyNaming")
const val `3GP` = "3gp"
const val ASF = "asf"
const val AVI = "avi"
const val DVR_MS = "dvr-ms"
const val M2V = "m2v"
const val M4V = "m4v"
const val MKV = "mkv"
const val MOV = "mov"
const val MP3 = "mp3"
const val MP4 = "mp4"
const val MPEG = "mpeg"
const val MPEGTS = "mpegts"
const val MPG = "mpg"
const val OGM = "ogm"
const val OGV = "ogv"
const val TS = "ts"
const val VOB = "vob"
const val WEBM = "webm"
const val WMV = "wmv"
const val WTV = "wtv"
const val XVID = "xvid"
}
object Audio {
const val AAC = "aac"
const val AAC_LATM = "aac_latm"
const val AC3 = "ac3"
const val ALAC = "alac"
const val APE = "ape"
const val DCA = "dca"
const val DTS = "dts"
const val EAC3 = "eac3"
const val FLAC = "flac"
const val MLP = "mlp"
const val MP2 = "mp2"
const val MP3 = "mp3"
const val MPA = "mpa"
const val OGA = "oga"
const val OGG = "ogg"
const val OPUS = "opus"
const val PCM = "pcm"
const val PCM_ALAW = "pcm_alaw"
const val PCM_MULAW = "pcm_mulaw"
const val PCM_S16LE = "pcm_s16le"
const val PCM_S24LE = "pcm_s24le"
const val TRUEHD = "truehd"
const val WAV = "wav"
const val WEBMA = "webma"
const val WMA = "wma"
const val WMAV2 = "wmav2"
}
object Video {
const val H264 = "h264"
const val HEVC = "hevc"
const val MPEG = "mpeg"
const val MPEG2VIDEO = "mpeg2video"
const val VP8 = "vp8"
const val VP9 = "vp9"
}
object Subtitle {
const val ASS = "ass"
const val DVDSUB = "dvdsub"
const val IDX = "idx"
const val PGS = "pgs"
const val PGSSUB = "pgssub"
const val SMI = "smi"
const val SRT = "srt"
const val SSA = "ssa"
const val SUB = "sub"
const val SUBRIP = "subrip"
const val VTT = "vtt"
const val SMIL = "smil"
const val TTML = "ttml"
const val WEBVTT = "webvtt"
}
}

View File

@@ -1,6 +0,0 @@
package org.jellyfin.androidtv.constant
sealed interface CustomMessage {
object RefreshCurrentItem : CustomMessage
object ActionComplete : CustomMessage
}

View File

@@ -1,6 +0,0 @@
package org.jellyfin.androidtv.constant
object Extras {
const val Folder = "folder"
const val IncludeType = "type_include"
}

View File

@@ -1,18 +0,0 @@
package org.jellyfin.androidtv.constant
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
enum class GridDirection {
/**
* Horizontal.
*/
@EnumDisplayOptions(R.string.grid_direction_horizontal)
HORIZONTAL,
/**
* Vertical.
*/
@EnumDisplayOptions(R.string.grid_direction_vertical)
VERTICAL,
}

View File

@@ -1,44 +0,0 @@
package org.jellyfin.androidtv.constant
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
import org.jellyfin.preference.PreferenceEnum
/**
* All possible homesections, "synced" with jellyfin-web.
*
* https://github.com/jellyfin/jellyfin-web/blob/master/src/components/homesections/homesections.js
*/
enum class HomeSectionType(
override val serializedName: String
) : PreferenceEnum {
@EnumDisplayOptions(R.string.home_section_latest_media)
LATEST_MEDIA("latestmedia"),
@EnumDisplayOptions(R.string.home_section_library)
LIBRARY_TILES_SMALL("smalllibrarytiles"),
@EnumDisplayOptions(R.string.home_section_library_small)
LIBRARY_BUTTONS("librarybuttons"),
@EnumDisplayOptions(R.string.home_section_resume)
RESUME("resume"),
@EnumDisplayOptions(R.string.home_section_resume_audio)
RESUME_AUDIO("resumeaudio"),
@EnumDisplayOptions(R.string.home_section_resume_book)
RESUME_BOOK("resumebook"),
@EnumDisplayOptions(R.string.home_section_active_recordings)
ACTIVE_RECORDINGS("activerecordings"),
@EnumDisplayOptions(R.string.home_section_next_up)
NEXT_UP("nextup"),
@EnumDisplayOptions(R.string.home_section_livetv)
LIVE_TV("livetv"),
@EnumDisplayOptions(R.string.home_section_none)
NONE("none"),
}

Some files were not shown because too many files have changed in this diff Show More