Compare commits

..

58 Commits

Author SHA1 Message Date
Niels van Velzen
a257651f8e Remove refreshing of Live TV program data in player 2024-05-28 16:34:45 +02:00
Niels van Velzen
0690c1c898 Use singleTask launch mode for StartupActivity
(cherry picked from commit 44a4cfec0c)
2024-05-28 16:06:42 +02:00
Niels van Velzen
66ec39f566 Support new intent in StartupActivity
(cherry picked from commit 879831d601)
2024-05-28 16:06:42 +02:00
Niels van Velzen
f4abd1246d Add app notification to notify about upcoming server requirement
(cherry picked from commit dd5d6e7b3e)
2024-05-26 22:06:52 +02:00
Niels van Velzen
a17821181f Force Quick Connect code to use LTR text direction
(cherry picked from commit 73626e185e)
2024-05-26 22:06:46 +02:00
Niels van Velzen
4da68f86cb Fix "Add server" button not visible when there are too many servers
(cherry picked from commit 5d0a677265)
2024-05-26 22:06:38 +02:00
Niels van Velzen
487bf3b7ee Revert "fix(deps): update dependency org.jellyfin.media3:media3-ffmpeg-decoder to v1.3.1+1"
This reverts commit 6031979f4c.
2024-04-24 20:48:02 +02:00
Niels van Velzen
21f5256e0f Revert "Fix channel adapter not using string resource"
This reverts commit 9c068290bb.
2024-04-18 22:24:03 +02:00
Niels van Velzen
41df9a59df Fix getAllChannelsIndex always returning -1
(cherry picked from commit a086a2d241)
2024-04-18 22:18:37 +02:00
Niels van Velzen
3709bf5f5a Fix channel adapter not using string resource
(cherry picked from commit 9c068290bb)
2024-04-18 22:18:00 +02:00
Niels van Velzen
3cdc1cdaba Fix Live TV channels refreshing to infinity
(cherry picked from commit 17f16208fd)
2024-04-18 22:17:57 +02:00
renovate[bot]
6031979f4c fix(deps): update dependency org.jellyfin.media3:media3-ffmpeg-decoder to v1.3.1+1
(cherry picked from commit 94ca41bae0)
2024-04-18 22:16:34 +02:00
renovate[bot]
896f66254f fix(deps): update androidx.media3 to v1.3.1
(cherry picked from commit e36268712b)
2024-04-18 22:16:33 +02:00
Niels van Velzen
3a18933b87 USe Timber.e instead of Exception.printStackTrace
(cherry picked from commit 9f90bf7c33)
2024-04-08 17:01:16 +02:00
Niels van Velzen
8c45084406 Update SDK to 1.4.7 (10.8 with enum backports) 2024-04-07 20:34:56 +02:00
Niels van Velzen
d4a297710b Fix app_logo cutoff in some cases
(cherry picked from commit dbde98ac50)
2024-04-07 10:32:11 +02:00
Niels van Velzen
19f30c19b0 Check if fragment is null in PlaybackController.play (#3475)
* Check if fragment is null in PlaybackController.play

* Update logging levels in PlaybackController

(cherry picked from commit d22ab694e2)
2024-04-07 10:32:10 +02:00
Niels van Velzen
4e8e8bc232 Fix search action ignoring received query
(cherry picked from commit 2784a1bcbb)
2024-03-12 15:20:31 +01:00
Niels van Velzen
ec5c751390 Update logo sizing in splash screen and screensaver
(cherry picked from commit b903bc0897)
2024-03-11 19:33:37 +01:00
Niels van Velzen
46b0b7aade Update app_logo to use new logo font
(cherry picked from commit 47282c09b0)
2024-03-11 19:33:36 +01:00
Niels van Velzen
3877739320 Fix background color for preference list views
(cherry picked from commit dd5002beac)
2024-03-11 19:33:36 +01:00
Niels van Velzen
538eeb4f22 Recreate icon/banner resources
(cherry picked from commit 07692d83b2)
2024-03-11 19:33:36 +01:00
renovate[bot]
0d724c532a fix(deps): update androidx.media3 to v1.3.0
(cherry picked from commit f2e802a300)
2024-03-11 19:33:29 +01:00
Niels van Velzen
eba14e0bf4 Use fully transparent background during video playback
(cherry picked from commit 60e694959c)
2024-03-11 19:32:59 +01:00
Joshua M. Boniface
a8c35b0ea0 Remove a target dir if it exists
Helps with potential tag overwrites should they occur.

(cherry picked from commit 07a3ef431a)
2024-03-11 19:32:59 +01:00
Joshua M. Boniface
17744729b2 Fix paths to use incoming and sudo move
Aligns with new permissions model on new repo server.

(cherry picked from commit eba5ef0e03)
2024-03-11 19:32:58 +01:00
Joshua M. Boniface
fd5442097e Update CI to new repository secrets and layout
(cherry picked from commit 6556c4c02e)
2024-03-11 19:32:35 +01:00
Niels van Velzen
f9eeae6bf0 Fix crash when checking whether a person is a folder or not
(cherry picked from commit efc6be8d3d)
2024-02-25 13:25:58 +01:00
Niels van Velzen
c2e33b9b13 Fix crash in SocketHandler.onPlayMessage when user id is not set
(cherry picked from commit 8b87dafb26)
2024-02-25 13:25:58 +01:00
Niels van Velzen
9c4b121574 Fix DPAD_CENTER on screensaver not handled
(cherry picked from commit fb25e79233)
2024-02-25 13:25:58 +01:00
Niels van Velzen
ddb1be18bc Replace CoroutineUtils.runBlocking with new CoroutineUtils.runOnLifecycle
This will prevent the main thread from blocking, which in extreme cases can kill the app

(cherry picked from commit bd704c4ac9)
2024-02-25 13:25:57 +01:00
Niels van Velzen
4a4c1ed6ca Fix crash when updating clock in FullDetailsFragment while item information is unavailable
(cherry picked from commit 68b578057d)
2024-02-25 13:25:57 +01:00
Niels van Velzen
c28759e165 Update app/src/main/java/org/jellyfin/androidtv/util/profile/MediaCodecCapabilitiesTest.kt
(cherry picked from commit d466420858)
2024-02-18 09:57:52 +01:00
lawadr
45a5adbc0b Fix up
(cherry picked from commit f5125e937a)
2024-02-18 09:57:42 +01:00
Niels van Velzen
118eacfa90 Specify BUILD_TOOLS_VERSION for r0adkll/sign-android-release step in publish workflow 2024-02-13 13:45:10 +01:00
MichaelR416
91ccf32f77 Fix media details tag for multiple versions support
(cherry picked from commit 9ec9de0ba1)
2024-02-11 16:14:29 +01:00
Niels van Velzen
065e738773 Only show PG-13 content in screensaver
(cherry picked from commit 5ff02c7e5d)
2024-02-11 16:12:35 +01:00
MichaelR416
65a7bbf501 Add if else statement for audio downmixing support
(cherry picked from commit d5c5bcc41c)
2024-02-11 16:12:30 +01:00
renovate[bot]
d661c2faf3 fix(deps): update dependency org.jellyfin.media3:media3-ffmpeg-decoder to v1.2.1+1
(cherry picked from commit 60eac66d90)
2024-02-11 16:12:14 +01:00
renovate[bot]
8175dbd1c0 fix(deps): update androidx.media3 to v1.2.1
(cherry picked from commit abf46d42f5)
2024-02-11 16:12:13 +01:00
lawadr
f068be6945 Widen support for HEVC Main 10
Previously the HEVC codec profile only flagged support for HEVC Main 10
when MediaCodec advertised its support for level 5 (main tier) or above.
This meant devices that support only levels 4 or 4.1 (such as the
Chromecast with Google TV HD) would transcode HEVC Main 10 content even
when supported.

This change lowers the requirement from level 5 to level 4 and also adds
additional profiles specifically for HEVC Main and Main 10 with
conditions on the level so that levels beyond what is supported by
MediaCodec get transcoded.

The result is that HEVC Main 10 level 5 videos get transcoded on devices
supporting only 4.1, but HEVC Main 10 level 4.1 content plays back
directly.

(cherry picked from commit 1fb3e0399c)
2024-02-11 16:11:41 +01:00
MichaelR416
fe465db020 Use LTR for default subtitle direction
(cherry picked from commit 17b76e1536)
2024-02-11 16:11:36 +01:00
Gwilherm Folliot
d6fe265b38 Fix crash when quitting player after two plays
If you play a title from a device and play another without quitting the player and then quit the player the app crash.

Its because the NavigationRepository stacks two players and when coming to the last one, the app crash because the last player is terminated.

My fix just replace the last destination if an fragment is active

java.lang.NullPointerException: Parameter specified as non-null is null: method androidx.activity.OnBackPressedDispatcher.addCallback, parameter onBackPressedCallback
	at androidx.activity.OnBackPressedDispatcher.addCallback(Unknown Source:3)
	at org.jellyfin.androidtv.ui.playback.CustomPlaybackOverlayFragment.onActivityCreated(CustomPlaybackOverlayFragment.java:346)
	at androidx.fragment.app.Fragment.performActivityCreated(Fragment.java:3156)
	at androidx.fragment.app.FragmentStateManager.activityCreated(FragmentStateManager.java:619)
	at androidx.fragment.app.FragmentStateManager.moveToExpectedState(FragmentStateManager.java:275)
	at androidx.fragment.app.FragmentManager.executeOpsTogether(FragmentManager.java:1934)
	at androidx.fragment.app.FragmentManager.removeRedundantOperationsAndExecute(FragmentManager.java:1839)
	at androidx.fragment.app.FragmentManager.execPendingActions(FragmentManager.java:1782)
	at androidx.fragment.app.FragmentManager$5.run(FragmentManager.java:565)
	at android.os.Handler.handleCallback(Handler.java:942)
	at android.os.Handler.dispatchMessage(Handler.java:99)
	at android.os.Looper.loopOnce(Looper.java:201)
	at android.os.Looper.loop(Looper.java:288)
	at android.app.ActivityThread.main(ActivityThread.java:7884)
	at java.lang.reflect.Method.invoke(Native Method)
	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936)

(cherry picked from commit 6867c319bb)
2024-02-11 16:11:29 +01:00
Niels van Velzen
8eecfab5d7 Disable tunneling 2024-01-07 14:53:05 +01:00
Niels van Velzen
0f9ffcacb9 Defer home initialization until user info is available
(cherry picked from commit d721482127)
2024-01-06 13:50:56 +01:00
Niels van Velzen
aa7c45d462 Disable crossfading for CardLegacyImageView to fix scaling issue
(cherry picked from commit 1fbc19740b)
2024-01-05 19:54:19 +01:00
Gwilherm Folliot
156f5ecd3c Fix crash when DISPLAY_CONTENT while playing
When a video is playing and on another device you browse libraries on a casting device it cause the app to crash

The onDisplayContent method is trying to read ExoPlayer state but from the socket thread and not the main thread

java.lang.IllegalStateException: Player is accessed on the wrong thread.
Current thread: 'DefaultDispatcher-worker-4'
Expected thread: 'main'
See https://developer.android.com/guide/topics/media/issues/player-accessed-on-wrong-thread
	at androidx.media3.exoplayer.ExoPlayerImpl.verifyApplicationThread(ExoPlayerImpl.java:2842)
	at androidx.media3.exoplayer.ExoPlayerImpl.getPlaybackState(ExoPlayerImpl.java:514)
	at androidx.media3.common.BasePlayer.isPlaying(BasePlayer.java:124)
	at org.jellyfin.androidtv.ui.playback.VideoManager.isPlaying(VideoManager.java:341)
	at org.jellyfin.androidtv.ui.playback.PlaybackController.isPlaying(PlaybackController.java:262)
	at org.jellyfin.androidtv.data.eventhandling.SocketHandler.onDisplayContent(SocketHandler.kt:184)
	at org.jellyfin.androidtv.data.eventhandling.SocketHandler.lambda$13$lambda$8(SocketHandler.kt:103)
	at org.jellyfin.androidtv.data.eventhandling.SocketHandler$$ExternalSyntheticLambda3.onReceive(Unknown Source:4)
	at org.jellyfin.androidtv.data.eventhandling.SocketHandler$_init_$lambda$13$$inlined$addGeneralCommandsListener$default$1.onReceive(ListenerRegistrationExtensions.kt:68)
	at org.jellyfin.sdk.api.sockets.helper.ListenerHelper.forwardMessage(ListenerHelper.kt:37)
	at org.jellyfin.sdk.api.sockets.SocketInstance.forwardMessage(SocketInstance.kt:283)
	at org.jellyfin.sdk.api.sockets.SocketInstance.access$forwardMessage(SocketInstance.kt:35)
	at org.jellyfin.sdk.api.sockets.SocketInstance$connectAndBind$2.invokeSuspend(SocketInstance.kt:220)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:108)
	at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:584)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:793)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:697)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:684)

(cherry picked from commit 0116530794)
2024-01-05 19:54:18 +01:00
Niels van Velzen
e56fd0d024 Set TsExtractor TimestampSearchBytes parameter based on device memory capabilities
(cherry picked from commit 76acd17d81)
2024-01-05 19:54:18 +01:00
Niels van Velzen
06e245fe48 Enable tunneling in VideoManager
(cherry picked from commit e646192196)
2024-01-05 19:54:17 +01:00
Niels van Velzen
9f0fa644a7 Enable tunneling in ExoPlayerBackend
(cherry picked from commit 1f0345f16f)
2024-01-05 19:54:17 +01:00
Niels van Velzen
5b213199dd Add <queries> element to Manifest for remote trailers
(cherry picked from commit b91fd1a0f5)
2024-01-05 19:54:16 +01:00
Niels van Velzen
b4f6273ede Disable SocketHandler when using RewriteMediaManager
(cherry picked from commit 332b6af3da)
2024-01-05 19:54:16 +01:00
Niels van Velzen
66c624e16c Replace ExoPlayer with androidx.media3
(cherry picked from commit bd204fdf87)
2024-01-05 19:54:15 +01:00
Niels van Velzen
e47916f1c2 Fix crash and inconsistent ordering when using ShuffleOrderIndexProvider
(cherry picked from commit b9e19b2515)
2024-01-05 19:54:15 +01:00
Niels van Velzen
ab7875cbb3 Fix opening collections via search results showing empty screen
(cherry picked from commit 2bbbb93dbf)
2024-01-05 19:54:14 +01:00
Niels van Velzen
5f15615a96 Fix playing audio from search queues entire row
(cherry picked from commit 8b43402192)
2024-01-05 19:54:14 +01:00
Niels van Velzen
9cda86ab84 Fix focus issue when pressing back in Fire TV soft keyboard
(cherry picked from commit c70f814064)
2024-01-05 19:54:14 +01:00
Niels van Velzen
1873497fec Fix RewriteMediaManager never used in production builds
(cherry picked from commit 75b6b12147)
2023-12-18 22:21:02 +01:00
468 changed files with 14691 additions and 18069 deletions

View File

@@ -2,17 +2,16 @@ root = true
[*]
insert_final_newline = true
max_line_length = 140
trim_trailing_whitespace = true
[{*.kts,*.kt}]
[{*.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 = unset
ij_kotlin_packages_to_use_import_on_demand =
[*.md]
trim_trailing_whitespace = false
trim_trailing_whitespace = false

View File

@@ -12,23 +12,23 @@ permissions:
jobs:
build:
name: Build
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Setup Java
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4.0.0
with:
distribution: temurin
java-version: 21
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
uses: gradle/gradle-build-action@8cbcb9948b5892387aed077daf6f90e1f0ba5b27 # v2.11.0
- 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@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
uses: actions/upload-artifact@c7d193f32edcb7bfad88892161225aeda64e9392 # v4.0.0
with:
name: build-artifacts
retention-days: 14

View File

@@ -14,21 +14,21 @@ permissions:
jobs:
lint:
name: Lint
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Setup Java
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4.0.0
with:
distribution: temurin
java-version: 21
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
uses: gradle/gradle-build-action@8cbcb9948b5892387aed077daf6f90e1f0ba5b27 # v2.11.0
- name: Run detekt and lint tasks
run: ./gradlew detekt lint
- name: Upload SARIF files
uses: github/codeql-action/upload-sarif@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
uses: github/codeql-action/upload-sarif@b374143c1149a9115d881581d29b8390bbcbb59c # v3.22.11
if: ${{ always() }}
with:
sarif_file: .

View File

@@ -8,18 +8,18 @@ on:
jobs:
publish:
name: Publish
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
if: ${{ contains(github.repository_owner, 'jellyfin') }}
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Setup Java
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4.0.0
with:
distribution: temurin
java-version: 21
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
uses: gradle/gradle-build-action@8cbcb9948b5892387aed077daf6f90e1f0ba5b27 # v2.11.0
- name: Set JELLYFIN_VERSION
run: echo "JELLYFIN_VERSION=$(echo ${GITHUB_REF#refs/tags/v} | tr / -)" >> $GITHUB_ENV
- name: Assemble release files
@@ -55,7 +55,7 @@ jobs:
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@13926a61cdb2cb35f5fdef1c06b8b591523236d3 # 0.4.1
uses: alexellis/upload-assets@259de5111cb56966d046ced998941e93f91d2c93 # tag=0.4.0
env:
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
with:
@@ -70,7 +70,7 @@ jobs:
remote_user: ${{ secrets.REPO_USER }}
remote_key: ${{ secrets.REPO_KEY }}
- name: Update repo.jellyfin.org symlinks
uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0
uses: appleboy/ssh-action@55dabf81b49d4120609345970c91507e2d734799 # v1.0.0
with:
host: ${{ secrets.REPO_HOST }}
username: ${{ secrets.REPO_USER }}

View File

@@ -13,16 +13,16 @@ permissions:
jobs:
test:
name: Test
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Setup Java
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4.0.0
with:
distribution: temurin
java-version: 21
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
uses: gradle/gradle-build-action@8cbcb9948b5892387aed077daf6f90e1f0ba5b27 # v2.11.0
- name: Run test task
run: ./gradlew test

View File

@@ -14,9 +14,9 @@ permissions:
jobs:
validate:
name: Validate
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
uses: gradle/wrapper-validation-action@56b90f209b02bf6d1deae490e9ef18b21a389cd4 # v1.1.0

View File

@@ -9,10 +9,10 @@ on:
jobs:
triage:
name: Triage
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
if: ${{ contains(github.repository_owner, 'jellyfin') }}
steps:
- uses: eps1lon/actions-label-merge-conflict@1b1b1fcde06a9b3d089f3464c96417961dde1168 # v3.0.2
- uses: eps1lon/actions-label-merge-conflict@fd1f295ee7443d13745804bc49fe158e240f6c6e # tag=v2.1.0
with:
dirtyLabel: merge conflict
repoToken: ${{ secrets.JF_BOT_TOKEN }}

View File

@@ -12,7 +12,7 @@ permissions:
jobs:
triage:
name: Triage
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
if: ${{ contains(github.repository_owner, 'jellyfin') }}
steps:
- uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0

1
.gitignore vendored
View File

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

View File

@@ -13,7 +13,6 @@
- [sparky3387](https://github.com/sparky3387)
- [mohd-akram](https://github.com/mohd-akram)
- [3l0w](https://github.com/3l0w)
- [MajMongoose](https://github.com/majmongoose)
# Emby Contributors

View File

@@ -0,0 +1,39 @@
buildscript {
repositories {
mavenCentral()
google()
}
dependencies {
classpath libs.android.gradle
}
}
import com.android.tools.profgen.ArtProfileKt
import com.android.tools.profgen.ArtProfileSerializer
import com.android.tools.profgen.DexFile
/**
* This is a temporary workaround for https://issuetracker.google.com/issues/231837768
* which will be fixed in AGP 8.1.0, at which point the workaround may be deleted.
*/
project.afterEvaluate {
tasks.compileReleaseArtProfile.doLast {
outputs.files.each { file ->
if (file.toString().endsWith(".profm")) {
println("Sorting ${file}")
def version = ArtProfileSerializer.valueOf("METADATA_0_0_2")
def profile = ArtProfileKt.ArtProfile(file)
def keys = new ArrayList(profile.profileData.keySet())
def sortedData = new LinkedHashMap()
Collections.sort keys, new DexFile.Companion()
keys.each { key -> sortedData[key] = profile.profileData[key] }
new FileOutputStream(file).with {
write(version.magicBytes$profgen)
write(version.versionBytes$profgen)
version.write$profgen(it, sortedData, "")
}
}
}
}
}

View File

@@ -2,17 +2,19 @@ plugins {
id("com.android.application")
kotlin("android")
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.aboutlibraries)
}
// Apply workaround
apply("baselineWorkaround.gradle")
android {
namespace = "org.jellyfin.androidtv"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compileSdk = 34
defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
minSdk = 21
targetSdk = 33
// Release version
applicationId = namespace
@@ -31,6 +33,10 @@ android {
isCoreLibraryDesugaringEnabled = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get()
}
buildTypes {
val release by getting {
isMinifyEnabled = false
@@ -80,7 +86,7 @@ aboutLibraries {
}
val versionTxt by tasks.registering {
val path = layout.buildDirectory.asFile.get().resolve("version.txt")
val path = buildDir.resolve("version.txt")
doLast {
val versionString = "v${android.defaultConfig.versionName}=${android.defaultConfig.versionCode}"
@@ -92,9 +98,9 @@ val versionTxt by tasks.registering {
dependencies {
// Jellyfin
implementation(projects.playback.core)
implementation(projects.playback.exoplayer)
implementation(projects.playback.jellyfin)
implementation(projects.playback.media3.exoplayer)
implementation(projects.playback.media3.session)
implementation(projects.playback.ui)
implementation(projects.preference)
implementation(libs.jellyfin.apiclient)
implementation(libs.jellyfin.sdk) {
@@ -115,7 +121,6 @@ dependencies {
implementation(libs.androidx.core)
implementation(libs.androidx.activity)
implementation(libs.androidx.fragment)
implementation(libs.androidx.fragment.compose)
implementation(libs.androidx.leanback.core)
implementation(libs.androidx.leanback.preference)
implementation(libs.androidx.preference)
@@ -129,7 +134,6 @@ dependencies {
implementation(libs.androidx.cardview)
implementation(libs.androidx.startup)
implementation(libs.bundles.androidx.compose)
implementation(libs.androidx.tv.material)
// Dependency Injection
implementation(libs.bundles.koin)
@@ -139,11 +143,13 @@ dependencies {
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.ui)
implementation(libs.jellyfin.androidx.media3.ffmpeg.decoder)
implementation(libs.libvlc)
// Markdown
implementation(libs.bundles.markwon)
// Image utility
implementation(libs.blurhash)
implementation(libs.bundles.coil)
// Crash Reporting
@@ -156,6 +162,10 @@ dependencies {
implementation(libs.timber)
implementation(libs.slf4j.timber)
// Debugging
if (getProperty("leakcanary.enable")?.toBoolean() == true)
debugImplementation(libs.leakcanary)
// Compatibility (desugaring)
coreLibraryDesugaring(libs.android.desugar)

View File

@@ -108,8 +108,8 @@
<activity
android:name=".ui.startup.StartupActivity"
android:exported="true"
android:launchMode="singleTask"
android:noHistory="true"
android:launchMode="singleTask"
android:screenOrientation="landscape"
android:windowSoftInputMode="adjustNothing">
<intent-filter>
@@ -132,7 +132,6 @@
<!-- Main application -->
<activity
android:name=".ui.browsing.MainActivity"
android:launchMode="singleTask"
android:screenOrientation="landscape"
android:windowSoftInputMode="adjustNothing" />
@@ -142,5 +141,8 @@
android:theme="@style/Theme.Jellyfin.Preferences" />
<activity android:name=".ui.playback.ExternalPlayerActivity" />
<!-- Playback Rewrite - Temporary -->
<activity android:name=".ui.playback.rewrite.PlaybackForwardingActivity" />
</application>
</manifest>

View File

@@ -2,11 +2,14 @@ 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.vanniktech.blurhash.BlurHash
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -15,6 +18,7 @@ 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
@@ -35,6 +39,7 @@ class JellyfinApplication : Application() {
*/
suspend fun onSessionStart() = withContext(Dispatchers.IO) {
val workManager by inject<WorkManager>()
val autoBitrate by inject<AutoBitrate>()
val socketListener by inject<SocketHandler>()
// Update background worker
@@ -54,6 +59,16 @@ class JellyfinApplication : Application() {
// 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()
}
override fun attachBaseContext(base: Context?) {

View File

@@ -6,7 +6,7 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.time.Instant
import java.util.Date
import java.util.UUID
/**
@@ -20,7 +20,7 @@ data class AuthenticationStoreServer(
@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 = Instant.now().toEpochMilli(),
@SerialName("last_refreshed") val lastRefreshed: Long = Instant.now().toEpochMilli(),
@SerialName("last_used") val lastUsed: Long = Date().time,
@SerialName("last_refreshed") val lastRefreshed: Long = Date().time,
val users: Map<UUID, AuthenticationStoreUser> = emptyMap(),
)

View File

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

View File

@@ -3,9 +3,9 @@ package org.jellyfin.androidtv.auth.model
import org.jellyfin.sdk.api.client.exception.ApiClientException
sealed class LoginState
data object AuthenticatingState : LoginState()
data object RequireSignInState : LoginState()
data object ServerUnavailableState : LoginState()
object AuthenticatingState : LoginState()
object RequireSignInState : LoginState()
object ServerUnavailableState : LoginState()
data class ServerVersionNotSupported(val server: Server) : LoginState()
data class ApiClientErrorLoginState(val error: ApiClientException) : LoginState()
data object AuthenticatedState : LoginState()
object AuthenticatedState : LoginState()

View File

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

View File

@@ -2,7 +2,7 @@ package org.jellyfin.androidtv.auth.model
import org.jellyfin.androidtv.auth.repository.ServerRepository
import org.jellyfin.sdk.model.ServerVersion
import java.time.Instant
import java.util.Date
import java.util.UUID
/**
@@ -16,7 +16,7 @@ data class Server(
val loginDisclaimer: String? = null,
val splashscreenEnabled: Boolean = false,
val setupCompleted: Boolean = true,
var dateLastAccessed: Instant = Instant.MIN,
var dateLastAccessed: Date = Date(0),
) {
private val serverVersion = version?.let(ServerVersion::fromString)
val versionSupported = serverVersion != null && serverVersion >= ServerRepository.minimumServerVersion

View File

@@ -10,6 +10,7 @@ sealed class User {
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
@@ -33,6 +34,7 @@ data class PrivateUser(
override val serverId: UUID,
override val name: String,
override val accessToken: String?,
override val requirePassword: Boolean,
override val imageTag: String?,
val lastUsed: Long,
) : User() {
@@ -47,6 +49,7 @@ data class PublicUser(
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

@@ -21,7 +21,7 @@ import org.jellyfin.androidtv.auth.model.ServerVersionNotSupported
import org.jellyfin.androidtv.auth.model.User
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.jellyfin.androidtv.util.ImageHelper
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
@@ -33,9 +33,10 @@ 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.time.Instant
import java.util.Date
/**
* Repository to manage authentication of the user in the app.
@@ -66,7 +67,7 @@ class AuthenticationRepositoryImpl(
}
private fun authenticateAutomatic(server: Server, user: User): Flow<LoginState> {
Timber.i("Authenticating user ${user.id}")
Timber.i("Authenticating user %s", user)
// Automatic logic is disabled when the always authenticate preference is enabled
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return flowOf(RequireSignInState)
@@ -74,6 +75,8 @@ class AuthenticationRepositoryImpl(
val authStoreUser = authenticationStore.getUser(server.id, user.id)
// Try login with access token
return if (authStoreUser?.accessToken != null) authenticateToken(server, user.withToken(authStoreUser.accessToken))
// Try login without password
else if (!user.requirePassword) authenticateCredential(server, user.name, "")
// Require login
else flowOf(RequireSignInState)
}
@@ -122,8 +125,9 @@ class AuthenticationRepositoryImpl(
serverId = server.id,
name = userInfo.name!!,
accessToken = result.accessToken,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag,
lastUsed = Instant.now().toEpochMilli(),
lastUsed = Date().time,
)
authenticateFinish(server, userInfo, accessToken)
@@ -162,11 +166,13 @@ class AuthenticationRepositoryImpl(
val updatedUser = currentUser?.copy(
name = userInfo.name!!,
lastUsed = Instant.now().toEpochMilli(),
lastUsed = Date().time,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag,
accessToken = accessToken,
) ?: AuthenticationStoreUser(
name = userInfo.name!!,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag,
accessToken = accessToken,
)
@@ -179,11 +185,11 @@ class AuthenticationRepositoryImpl(
if (authenticated) {
// Update last use in store
authenticationStore.getServer(server.id)?.let { storedServer ->
authenticationStore.putServer(server.id, storedServer.copy(lastUsed = Instant.now().toEpochMilli()))
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 = Instant.now().toEpochMilli()))
authenticationStore.putUser(server.id, user.id, storedUser.copy(lastUsed = Date().time))
}
}
@@ -203,7 +209,8 @@ class AuthenticationRepositoryImpl(
jellyfin.createApi(server.address).imageApi.getUserImageUrl(
userId = user.id,
tag = tag,
maxHeight = ImageHelper.MAX_PRIMARY_IMAGE_HEIGHT
imageType = ImageType.PRIMARY,
maxHeight = ImageUtils.MAX_PRIMARY_IMAGE_HEIGHT
)
}
}

View File

@@ -27,7 +27,7 @@ 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.time.Instant
import java.util.Date
import java.util.UUID
/**
@@ -49,7 +49,7 @@ interface ServerRepository {
val minimumServerVersion = Jellyfin.minimumVersion.copy(build = null)
val recommendedServerVersion = Jellyfin.apiVersion.copy(build = null)
val upcomingMinimumServerVersion = ServerVersion(10, 10, 0)
val upcomingMinimumServerVersion = ServerVersion(10, 9, 0)
}
}
@@ -95,7 +95,7 @@ class ServerRepositoryImpl(
val goodRecommendations = mutableListOf<RecommendedServerInfo>()
val badRecommendations = mutableListOf<RecommendedServerInfo>()
val greatRecommendation = jellyfin.discovery.getRecommendedServers(addressCandidates).firstOrNull { recommendedServer ->
val greatRecommendaton = jellyfin.discovery.getRecommendedServers(addressCandidates).firstOrNull { recommendedServer ->
when (recommendedServer.score) {
RecommendedServerInfoScore.GREAT -> true
RecommendedServerInfoScore.GOOD -> {
@@ -111,7 +111,7 @@ class ServerRepositoryImpl(
Timber.d(buildString {
append("Recommendations: ")
if (greatRecommendation == null) append(0)
if (greatRecommendaton == null) append(0)
else append(1)
append(" great, ")
append(goodRecommendations.size)
@@ -120,7 +120,7 @@ class ServerRepositoryImpl(
append(" bad")
})
val chosenRecommendation = greatRecommendation ?: goodRecommendations.firstOrNull()
val chosenRecommendation = greatRecommendaton ?: goodRecommendations.firstOrNull()
if (chosenRecommendation != null && chosenRecommendation.systemInfo.isSuccess) {
// Get system info
val systemInfo = chosenRecommendation.systemInfo.getOrThrow()
@@ -138,7 +138,7 @@ class ServerRepositoryImpl(
loginDisclaimer = branding.loginDisclaimer,
splashscreenEnabled = branding.splashscreenEnabled,
setupCompleted = systemInfo.startupWizardCompleted ?: true,
lastUsed = Instant.now().toEpochMilli()
lastUsed = Date().time
) ?: AuthenticationStoreServer(
name = systemInfo.serverName ?: "Jellyfin Server",
address = chosenRecommendation.address,
@@ -188,7 +188,7 @@ class ServerRepositoryImpl(
}
private suspend fun updateServerInternal(id: UUID, server: AuthenticationStoreServer): AuthenticationStoreServer? {
val now = Instant.now().toEpochMilli()
val now = Date().time
// Only update every 10 minutes
if (now - server.lastRefreshed < 600000 && server.version != null) return null
@@ -226,7 +226,7 @@ class ServerRepositoryImpl(
loginDisclaimer = loginDisclaimer,
splashscreenEnabled = splashscreenEnabled,
setupCompleted = setupCompleted,
dateLastAccessed = Instant.ofEpochMilli(lastUsed),
dateLastAccessed = Date(lastUsed),
)
/**

View File

@@ -35,6 +35,7 @@ class ServerUserRepositoryImpl(
serverId = server.id,
name = userInfo.name,
accessToken = authInfo?.accessToken,
requirePassword = userInfo.requirePassword,
imageTag = userInfo.imageTag,
lastUsed = userInfo.lastUsed,
)

View File

@@ -141,6 +141,11 @@ class SessionRepositoryImpl(
val applied = userApiClient.applySession(session, deviceInfo)
if (applied && 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)
@@ -149,11 +154,6 @@ class SessionRepositoryImpl(
destroyCurrentSession()
return false
}
// Update crash reporting URL
val crashReportUrl = userApiClient.clientLogApi.logFileUrl()
telemetryPreferences[TelemetryPreferences.crashReportUrl] = crashReportUrl
telemetryPreferences[TelemetryPreferences.crashReportToken] = session.accessToken
} else {
userRepository.updateCurrentUser(null)
}
@@ -184,21 +184,18 @@ class SessionRepositoryImpl(
}
private fun ApiClient.applySession(session: Session?, newDeviceInfo: DeviceInfo = defaultDeviceInfo): Boolean {
deviceInfo = newDeviceInfo
if (session == null) {
update(
baseUrl = null,
accessToken = null,
deviceInfo = newDeviceInfo,
)
baseUrl = null
accessToken = null
userId = null
} else {
val server = authenticationStore.getServer(session.serverId)
?: return false
update(
baseUrl = server.address,
accessToken = session.accessToken,
deviceInfo = newDeviceInfo,
)
baseUrl = server.address
accessToken = session.accessToken
userId = session.userId
}
return true

View File

@@ -17,6 +17,6 @@ class UserRepositoryImpl : UserRepository {
override val currentUser = MutableStateFlow<UserDto?>(null)
override fun updateCurrentUser(user: UserDto?) {
currentUser.value = user
if (currentUser.value !== user) currentUser.value = user
}
}

View File

@@ -4,6 +4,9 @@ enum class ChangeTriggerType {
LibraryUpdated,
MoviePlayback,
TvPlayback,
GuideNeedsLoad,
MusicPlayback,
Always,
VideoQueueChange,
FavoriteUpdate,
}

View File

@@ -7,7 +7,6 @@ object Codec {
const val ASF = "asf"
const val AVI = "avi"
const val DVR_MS = "dvr-ms"
const val HLS = "hls"
const val M2V = "m2v"
const val M4V = "m4v"
const val MKV = "mkv"
@@ -48,10 +47,8 @@ object Codec {
const val PCM_ALAW = "pcm_alaw"
const val PCM_MULAW = "pcm_mulaw"
const val PCM_S16LE = "pcm_s16le"
const val PCM_S20LE = "pcm_s20le"
const val PCM_S24LE = "pcm_s24le"
const val TRUEHD = "truehd"
const val VORBIS = "vorbis"
const val WAV = "wav"
const val WEBMA = "webma"
const val WMA = "wma"

View File

@@ -2,6 +2,5 @@ package org.jellyfin.androidtv.constant
object Extras {
const val Folder = "folder"
const val IsLiveTvSeriesRecordings = "is_livetv_series_recordings"
const val IncludeType = "type_include"
}

View File

@@ -5,7 +5,8 @@ import org.jellyfin.androidtv.R
@Suppress("MagicNumber")
private val qualityOptions = setOf(
200.0, 180.0, 140.0, 120.0, 110.0, 100.0, // 100 >=
0.0, // auto
120.0, 110.0, 100.0, // 100 >=
90.0, 80.0, 70.0, 60.0, 50.0, 40.0, 30.0, 20.0, 15.0, 10.0, // 10 >=
5.0, 3.0, 2.0, 1.0, // 1 >=
0.72, 0.42 // 0 >=
@@ -16,6 +17,7 @@ fun getQualityProfiles(
context: Context
): Map<String, String> = qualityOptions.associate {
val value = when {
it == 0.0 -> context.getString(R.string.bitrate_auto)
it >= 1.0 -> context.getString(R.string.bitrate_mbit, it)
else -> context.getString(R.string.bitrate_kbit, it * 1000.0)
}

View File

@@ -18,6 +18,7 @@ enum class QueryType {
LiveTvProgram,
LiveTvRecording,
StaticItems,
Persons,
StaticAudioQueueItems,
Artists,
AlbumArtists,

View File

@@ -3,12 +3,11 @@ package org.jellyfin.androidtv.data.compat
import org.jellyfin.apiclient.model.dlna.DeviceProfile
import org.jellyfin.apiclient.model.dlna.EncodingContext
import org.jellyfin.sdk.model.api.MediaSourceInfo
import java.util.UUID
open class AudioOptions {
var enableDirectPlay = true
var enableDirectStream = true
var itemId: UUID? = null
var itemId: String? = null
var mediaSources: List<MediaSourceInfo>? = null
var profile: DeviceProfile? = null

View File

@@ -1,28 +1,37 @@
package org.jellyfin.androidtv.data.compat;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.apiclient.model.dlna.DeviceProfile;
import org.jellyfin.apiclient.model.dlna.DlnaProfileType;
import org.jellyfin.apiclient.model.dlna.EncodingContext;
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod;
import org.jellyfin.apiclient.model.dlna.SubtitleProfile;
import org.jellyfin.apiclient.model.dlna.TranscodeSeekInfo;
import org.jellyfin.apiclient.model.dto.NameValuePair;
import org.jellyfin.apiclient.model.session.PlayMethod;
import org.jellyfin.sdk.api.client.ApiClient;
import org.jellyfin.sdk.model.api.MediaProtocol;
import org.jellyfin.sdk.model.api.MediaSourceInfo;
import org.jellyfin.sdk.model.api.MediaStream;
import org.jellyfin.sdk.model.api.MediaStreamType;
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.UUID;
public class StreamInfo {
private UUID ItemId;
private static final String START_TIME_TICKS = "StartTimeTicks";
private static final String SUBTITLE_STREAM_INDEX = "SubtitleStreamIndex";
private static final String STATIC = "Static";
public final UUID getItemId() {
public StreamInfo() {
setAudioCodecs(new String[]{});
}
private String ItemId;
public final String getItemId() {
return ItemId;
}
public final void setItemId(UUID value) {
public final void setItemId(String value) {
ItemId = value;
}
@@ -36,14 +45,14 @@ public class StreamInfo {
MediaUrl = value;
}
private PlayMethod playMethod = PlayMethod.DirectPlay;
private PlayMethod PlayMethod = getPlayMethod().values()[0];
public final PlayMethod getPlayMethod() {
return playMethod;
return PlayMethod;
}
public final void setPlayMethod(PlayMethod value) {
playMethod = value;
PlayMethod = value;
}
private EncodingContext Context = EncodingContext.values()[0];
@@ -56,6 +65,16 @@ public class StreamInfo {
Context = value;
}
private DlnaProfileType MediaType = DlnaProfileType.values()[0];
public final DlnaProfileType getMediaType() {
return MediaType;
}
public final void setMediaType(DlnaProfileType value) {
MediaType = value;
}
private String Container;
public final String getContainer() {
@@ -66,6 +85,26 @@ public class StreamInfo {
Container = value;
}
private long StartPositionTicks;
public final long getStartPositionTicks() {
return StartPositionTicks;
}
public final void setStartPositionTicks(long value) {
StartPositionTicks = value;
}
private String[] AudioCodecs;
public final String[] getAudioCodecs() {
return AudioCodecs;
}
public final void setAudioCodecs(String[] value) {
AudioCodecs = value;
}
private DeviceProfile DeviceProfile;
public final DeviceProfile getDeviceProfile() {
@@ -76,6 +115,16 @@ public class StreamInfo {
DeviceProfile = value;
}
private String DeviceId;
public final String getDeviceId() {
return DeviceId;
}
public final void setDeviceId(String value) {
DeviceId = value;
}
private Long RunTimeTicks = null;
public final Long getRunTimeTicks() {
@@ -102,10 +151,10 @@ public class StreamInfo {
MediaSource = value;
}
public final org.jellyfin.sdk.model.api.SubtitleDeliveryMethod getSubtitleDeliveryMethod() {
Integer subtitleStreamIndex = MediaSource.getDefaultSubtitleStreamIndex();
if (subtitleStreamIndex == null || subtitleStreamIndex == -1) return SubtitleDeliveryMethod.DROP;
return MediaSource.getMediaStreams().get(subtitleStreamIndex).getDeliveryMethod();
private SubtitleDeliveryMethod SubtitleDeliveryMethod = getSubtitleDeliveryMethod().values()[0];
public final SubtitleDeliveryMethod getSubtitleDeliveryMethod() {
return SubtitleDeliveryMethod;
}
private String PlaySessionId;
@@ -122,33 +171,182 @@ public class StreamInfo {
return getMediaSource() == null ? null : getMediaSource().getId();
}
public final ArrayList<SubtitleStreamInfo> getSubtitleProfiles(ApiClient api) {
public final boolean getIsDirectStream() {
return getPlayMethod() == PlayMethod.DirectStream || getPlayMethod() == PlayMethod.DirectPlay;
}
public final String toUrl(String baseUrl, String accessToken) {
if (!Utils.isEmpty(getMediaUrl())) {
return getMediaUrl();
}
if (getPlayMethod() == PlayMethod.DirectPlay) {
return getMediaSource().getPath();
}
if (Utils.isEmpty(baseUrl)) {
throw new IllegalArgumentException(baseUrl);
}
ArrayList<String> list = new ArrayList<String>();
for (NameValuePair pair : buildParams(this, accessToken, false)) {
if (Utils.isEmpty(pair.getValue())) {
continue;
}
// Try to keep the url clean by omitting defaults
if (START_TIME_TICKS.equalsIgnoreCase(pair.getName()) && "0".equalsIgnoreCase(pair.getValue())) {
continue;
}
if (SUBTITLE_STREAM_INDEX.equalsIgnoreCase(pair.getName()) && "-1".equalsIgnoreCase(pair.getValue())) {
continue;
}
if (STATIC.equalsIgnoreCase(pair.getName()) && "false".equalsIgnoreCase(pair.getValue())) {
continue;
}
list.add(String.format("%1$s=%2$s", pair.getName(), pair.getValue()));
}
String queryString = Utils.join("&", list);
return getUrl(baseUrl, queryString);
}
private String getUrl(String baseUrl, String queryString) {
if (Utils.isEmpty(baseUrl)) {
throw new IllegalArgumentException(baseUrl);
}
String extension = Utils.isEmpty(getContainer()) ? "" : "." + getContainer();
// remove trailing slashes
baseUrl = baseUrl.replaceAll("[/]+$", "");
if (getMediaType() == DlnaProfileType.Audio) {
return String.format("%1$s/audio/%2$s/stream%3$s?%4$s", baseUrl, getItemId(), extension, queryString);
}
return String.format("%1$s/videos/%2$s/stream%3$s?%4$s", baseUrl, getItemId(), extension, queryString);
}
private static ArrayList<NameValuePair> buildParams(StreamInfo item, String accessToken, boolean isDlna) {
ArrayList<NameValuePair> list = new ArrayList<NameValuePair>();
String audioCodecs = item.getAudioCodecs().length == 0 ? "" : Utils.join(",", item.getAudioCodecs());
list.add(new NameValuePair("DeviceProfileId", ""));
String tempVar2 = item.getDeviceId();
list.add(new NameValuePair("DeviceId", (tempVar2 != null) ? tempVar2 : ""));
String tempVar3 = item.getMediaSourceId();
list.add(new NameValuePair("MediaSourceId", (tempVar3 != null) ? tempVar3 : ""));
list.add(new NameValuePair(STATIC, Boolean.valueOf(item.getIsDirectStream()).toString().toLowerCase()));
list.add(new NameValuePair("VideoCodec", ""));
list.add(new NameValuePair("AudioCodec", audioCodecs));
list.add(new NameValuePair("AudioStreamIndex", ""));
list.add(new NameValuePair("SubtitleStreamIndex", ""));
list.add(new NameValuePair("VideoBitrate", ""));
list.add(new NameValuePair("AudioBitrate", ""));
list.add(new NameValuePair("MaxAudioChannels", ""));
list.add(new NameValuePair("MaxFramerate", ""));
list.add(new NameValuePair("MaxWidth", ""));
list.add(new NameValuePair("MaxHeight", ""));
list.add(new NameValuePair(START_TIME_TICKS, String.valueOf(item.getStartPositionTicks())));
list.add(new NameValuePair("Level", ""));
list.add(new NameValuePair("MaxRefFrames", ""));
list.add(new NameValuePair("MaxVideoBitDepth", ""));
list.add(new NameValuePair("Profile", ""));
// no longer used
list.add(new NameValuePair("Cabac", ""));
String tempVar6 = item.getPlaySessionId();
list.add(new NameValuePair("PlaySessionId", (tempVar6 != null) ? tempVar6 : ""));
list.add(new NameValuePair("api_key", (accessToken != null) ? accessToken : ""));
String liveStreamId = item.getMediaSource() == null ? null : item.getMediaSource().getLiveStreamId();
list.add(new NameValuePair("LiveStreamId", (liveStreamId != null) ? liveStreamId : ""));
if (isDlna) {
list.add(new NameValuePair("ItemId", item.getItemId()));
}
list.add(new NameValuePair("CopyTimestamps", "false"));
list.add(new NameValuePair("ForceLiveStream", "false"));
list.add(new NameValuePair("SubtitleMethod", ""));
list.add(new NameValuePair("TranscodingMaxAudioChannels", ""));
list.add(new NameValuePair("EnableSubtitlesInManifest", "false"));
String tempVar7 = item.getMediaSource().getETag();
list.add(new NameValuePair("Tag", (tempVar7 != null) ? tempVar7 : ""));
return list;
}
public final ArrayList<SubtitleStreamInfo> getSubtitleProfiles(boolean includeSelectedTrackOnly, String baseUrl, String accessToken) {
return getSubtitleProfiles(includeSelectedTrackOnly, false, baseUrl, accessToken);
}
public final ArrayList<SubtitleStreamInfo> getSubtitleProfiles(boolean includeSelectedTrackOnly, boolean enableAllProfiles, String baseUrl, String accessToken) {
ArrayList<SubtitleStreamInfo> list = new ArrayList<SubtitleStreamInfo>();
if (getMediaSource() == null) return list;
// HLS will preserve timestamps so we can just grab the full subtitle stream
long startPositionTicks = getPlayMethod() == PlayMethod.Transcode ? getStartPositionTicks() : 0;
for (org.jellyfin.sdk.model.api.MediaStream stream : getMediaSource().getMediaStreams()) {
if (stream.getType() == org.jellyfin.sdk.model.api.MediaStreamType.SUBTITLE) {
SubtitleStreamInfo info = getSubtitleStreamInfo(api, stream, getDeviceProfile().getSubtitleProfiles());
list.add(info);
if (!includeSelectedTrackOnly) {
if (getMediaSource() == null) return list;
for (org.jellyfin.sdk.model.api.MediaStream stream : getMediaSource().getMediaStreams()) {
if (stream.getType() == org.jellyfin.sdk.model.api.MediaStreamType.SUBTITLE) {
addSubtitleProfiles(list, stream, enableAllProfiles, baseUrl, accessToken, startPositionTicks);
}
}
}
return list;
}
private SubtitleStreamInfo getSubtitleStreamInfo(ApiClient api, org.jellyfin.sdk.model.api.MediaStream stream, SubtitleProfile[] subtitleProfiles) {
SubtitleProfile subtitleProfile = StreamBuilder.getSubtitleProfile(stream, subtitleProfiles, getPlayMethod());
SubtitleStreamInfo info = new SubtitleStreamInfo();
String tempVar2 = stream.getLanguage();
info.setName((tempVar2 != null) ? tempVar2 : "Unknown");
info.setFormat(subtitleProfile.getFormat());
info.setIndex(stream.getIndex());
info.setDeliveryMethod(subtitleProfile.getMethod());
info.setDisplayTitle(stream.getDisplayTitle());
if (stream.getDeliveryUrl() != null) {
info.setUrl(api.createUrl(stream.getDeliveryUrl(), new HashMap<>(), new HashMap<>(), true));
private void addSubtitleProfiles(ArrayList<SubtitleStreamInfo> list, org.jellyfin.sdk.model.api.MediaStream stream, boolean enableAllProfiles, String baseUrl, String accessToken, long startPositionTicks) {
if (enableAllProfiles) {
for (SubtitleProfile profile : getDeviceProfile().getSubtitleProfiles()) {
SubtitleStreamInfo info = getSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, new SubtitleProfile[]{profile});
list.add(info);
}
} else {
SubtitleStreamInfo info = getSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, getDeviceProfile().getSubtitleProfiles());
list.add(info);
}
}
private SubtitleStreamInfo getSubtitleStreamInfo(org.jellyfin.sdk.model.api.MediaStream stream, String baseUrl, String accessToken, long startPositionTicks, SubtitleProfile[] subtitleProfiles) {
SubtitleProfile subtitleProfile = StreamBuilder.getSubtitleProfile(stream, subtitleProfiles, getPlayMethod());
SubtitleStreamInfo tempVar = new SubtitleStreamInfo();
String tempVar2 = stream.getLanguage();
tempVar.setName((tempVar2 != null) ? tempVar2 : "Unknown");
tempVar.setFormat(subtitleProfile.getFormat());
tempVar.setIndex(stream.getIndex());
tempVar.setDeliveryMethod(subtitleProfile.getMethod());
tempVar.setDisplayTitle(stream.getDisplayTitle());
SubtitleStreamInfo info = tempVar;
if (info.getDeliveryMethod() == SubtitleDeliveryMethod.External) {
if (getMediaSource().getProtocol() == MediaProtocol.FILE || !stream.getCodec().equalsIgnoreCase(subtitleProfile.getFormat())) {
info.setUrl(String.format("%1$s/Videos/%2$s/%3$s/Subtitles/%4$s/%5$s/Stream.%6$s", baseUrl, getItemId(), getMediaSourceId(), String.valueOf(stream.getIndex()), String.valueOf(startPositionTicks), subtitleProfile.getFormat()));
if (!Utils.isEmpty(accessToken)) {
info.setUrl(info.getUrl() + "?api_key=" + accessToken);
}
} else {
info.setUrl(stream.getPath());
}
}
return info;
}

View File

@@ -6,62 +6,57 @@ import android.os.Build
import android.widget.Toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.data.model.DataRefreshService
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
import org.jellyfin.androidtv.ui.navigation.Destinations
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
import org.jellyfin.androidtv.ui.playback.setSubtitleIndex
import org.jellyfin.androidtv.util.PlaybackHelper
import org.jellyfin.androidtv.ui.playback.rewrite.RewriteMediaManager
import org.jellyfin.androidtv.util.apiclient.PlaybackHelper
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.sessionApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.sockets.subscribe
import org.jellyfin.sdk.api.sockets.subscribeGeneralCommand
import org.jellyfin.sdk.api.sockets.subscribeGeneralCommands
import org.jellyfin.sdk.api.sockets.SocketInstance
import org.jellyfin.sdk.api.sockets.SocketInstanceState
import org.jellyfin.sdk.api.sockets.addGeneralCommandsListener
import org.jellyfin.sdk.api.sockets.addListener
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.GeneralCommandType
import org.jellyfin.sdk.model.api.LibraryChangedMessage
import org.jellyfin.sdk.model.api.LibraryUpdateInfo
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.PlayMessage
import org.jellyfin.sdk.model.api.PlaystateCommand
import org.jellyfin.sdk.model.api.PlaystateMessage
import org.jellyfin.sdk.model.extensions.get
import org.jellyfin.sdk.model.constant.MediaType
import org.jellyfin.sdk.model.extensions.getValue
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import org.jellyfin.sdk.model.socket.LibraryChangedMessage
import org.jellyfin.sdk.model.socket.PlayMessage
import org.jellyfin.sdk.model.socket.PlayStateMessage
import timber.log.Timber
import java.time.Instant
import java.util.UUID
class SocketHandler(
private val context: Context,
private val api: ApiClient,
private val socketInstance: SocketInstance,
private val dataRefreshService: DataRefreshService,
private val mediaManager: MediaManager,
private val playbackControllerContainer: PlaybackControllerContainer,
private val navigationRepository: NavigationRepository,
private val audioManager: AudioManager,
private val itemLauncher: ItemLauncher,
private val playbackHelper: PlaybackHelper,
) {
private val coroutineScope = CoroutineScope(Dispatchers.IO)
val state = socketInstance.state
suspend fun updateSession() {
try {
api.sessionApi.postCapabilities(
playableMediaTypes = listOf(MediaType.VIDEO, MediaType.AUDIO),
playableMediaTypes = listOf(MediaType.Video, MediaType.Audio),
supportsMediaControl = true,
supportedCommands = buildList {
add(GeneralCommandType.DISPLAY_CONTENT)
add(GeneralCommandType.SET_SUBTITLE_STREAM_INDEX)
add(GeneralCommandType.SET_AUDIO_STREAM_INDEX)
add(GeneralCommandType.DISPLAY_MESSAGE)
add(GeneralCommandType.SEND_STRING)
@@ -82,70 +77,38 @@ class SocketHandler(
Timber.e(err, "Unable to update capabilities")
return
}
val isOffline = state.value == SocketInstanceState.DISCONNECTED || state.value == SocketInstanceState.ERROR
if (isOffline) socketInstance.reconnect()
else socketInstance.updateCredentials()
}
init {
api.webSocket.apply {
socketInstance.apply {
// Library
subscribe<LibraryChangedMessage>()
.onEach { message -> message.data?.let(::onLibraryChanged) }
.launchIn(coroutineScope)
addListener<LibraryChangedMessage> { message -> onLibraryChanged(message.info) }
// Media playback
subscribe<PlayMessage>()
.onEach { message -> onPlayMessage(message) }
.launchIn(coroutineScope)
subscribe<PlaystateMessage>()
.onEach { message -> onPlayStateMessage(message) }
.launchIn(coroutineScope)
subscribeGeneralCommand(GeneralCommandType.SET_SUBTITLE_STREAM_INDEX)
.onEach { message ->
val index = message["index"]?.toIntOrNull() ?: return@onEach
withContext(Dispatchers.Main) {
playbackControllerContainer.playbackController?.setSubtitleIndex(index)
}
}
.launchIn(coroutineScope)
subscribeGeneralCommand(GeneralCommandType.SET_AUDIO_STREAM_INDEX)
.onEach { message ->
val index = message["index"]?.toIntOrNull() ?: return@onEach
withContext(Dispatchers.Main) {
playbackControllerContainer.playbackController?.switchAudioStream(index)
}
}
.launchIn(coroutineScope)
addListener<PlayMessage> { message -> onPlayMessage(message) }
addListener<PlayStateMessage> { message -> onPlayStateMessage(message) }
// General commands
subscribeGeneralCommand(GeneralCommandType.DISPLAY_CONTENT)
.onEach { message ->
val itemId by message
val itemType by message
addGeneralCommandsListener(setOf(GeneralCommandType.DISPLAY_CONTENT)) { message ->
val itemId by message
val itemType by message
val itemUuid = itemId?.toUUIDOrNull()
val itemKind = itemType?.let { type ->
BaseItemKind.entries.find { value ->
value.serialName.equals(type, true)
}
}
val itemUuid = itemId?.toUUIDOrNull()
val itemKind = itemType?.let { type -> BaseItemKind.values().find { value -> value.serialName.equals(type, true) } }
if (itemUuid != null && itemKind != null) onDisplayContent(itemUuid, itemKind)
}
.launchIn(coroutineScope)
if (itemUuid != null && itemKind != null) onDisplayContent(itemUuid, itemKind)
}
addGeneralCommandsListener(setOf(GeneralCommandType.DISPLAY_MESSAGE, GeneralCommandType.SEND_STRING)) { message ->
val header by message
val text by message
val string by message
subscribeGeneralCommands(setOf(GeneralCommandType.DISPLAY_MESSAGE, GeneralCommandType.SEND_STRING))
.onEach { message ->
val header by message
val text by message
val string by message
onDisplayMessage(header, text ?: string)
}
.launchIn(coroutineScope)
onDisplayMessage(header, text ?: string)
}
}
}
@@ -158,69 +121,80 @@ class SocketHandler(
})
if (info.itemsAdded.any() || info.itemsRemoved.any())
dataRefreshService.lastLibraryChange = Instant.now()
dataRefreshService.lastLibraryChange = System.currentTimeMillis()
}
private fun onPlayMessage(message: PlayMessage) {
val itemId = message.data?.itemIds?.firstOrNull() ?: return
val itemId = message.request.itemIds?.firstOrNull() ?: return
runCatching {
playbackHelper.retrieveAndPlay(
itemId,
PlaybackHelper.retrieveAndPlay(
itemId.toString(),
false,
message.data?.startPositionTicks,
message.request.startPositionTicks,
context
)
}.onFailure { Timber.w(it, "Failed to start playback") }
}
@Suppress("ComplexMethod")
private suspend fun onPlayStateMessage(message: PlaystateMessage) = withContext(Dispatchers.Main) {
Timber.i("Received PlayStateMessage with command ${message.data?.command}")
private fun onPlayStateMessage(message: PlayStateMessage) = coroutineScope.launch(Dispatchers.Main) {
Timber.i("Received PlayStateMessage with command ${message.request.command}")
// Audio playback uses (Rewrite)MediaManager, (legacy) video playback uses playbackController
when {
mediaManager.hasAudioQueueItems() -> {
// Ignore RewriteMediaManager
mediaManager is RewriteMediaManager && mediaManager.hasAudioQueueItems() -> {
Timber.i("Ignoring PlayStateMessage: should be handled by PlaySessionSocketService")
return@withContext
return@launch
}
// LegacyMediaManager
mediaManager.hasAudioQueueItems() -> when (message.request.command) {
PlaystateCommand.STOP -> mediaManager.stopAudio(true)
PlaystateCommand.PAUSE, PlaystateCommand.UNPAUSE, PlaystateCommand.PLAY_PAUSE -> mediaManager.playPauseAudio()
PlaystateCommand.NEXT_TRACK -> mediaManager.nextAudioItem()
PlaystateCommand.PREVIOUS_TRACK -> mediaManager.prevAudioItem()
// Not implemented
PlaystateCommand.SEEK,
PlaystateCommand.REWIND,
PlaystateCommand.FAST_FORWARD -> Unit
}
// PlaybackController
else -> {
val playbackController = playbackControllerContainer.playbackController
when (message.data?.command) {
when (message.request.command) {
PlaystateCommand.STOP -> playbackController?.endPlayback(true)
PlaystateCommand.PAUSE, PlaystateCommand.UNPAUSE, PlaystateCommand.PLAY_PAUSE -> playbackController?.playPause()
PlaystateCommand.NEXT_TRACK -> playbackController?.next()
PlaystateCommand.PREVIOUS_TRACK -> playbackController?.prev()
PlaystateCommand.SEEK -> playbackController?.seek(
(message.data?.seekPositionTicks ?: 0) / TICKS_TO_MS
(message.request.seekPositionTicks ?: 0) / TICKS_TO_MS
)
PlaystateCommand.REWIND -> playbackController?.rewind()
PlaystateCommand.FAST_FORWARD -> playbackController?.fastForward()
null -> Unit
}
}
}
}
private suspend fun onDisplayContent(itemId: UUID, itemKind: BaseItemKind) = withContext(Dispatchers.Main) {
private fun onDisplayContent(itemId: UUID, itemKind: BaseItemKind) = coroutineScope.launch(Dispatchers.Main) {
val playbackController = playbackControllerContainer.playbackController
if (playbackController?.isPlaying == true || playbackController?.isPaused == true) {
Timber.i("Not launching $itemId: playback in progress")
return@withContext
return@launch
}
Timber.i("Launching $itemId")
when (itemKind) {
BaseItemKind.USER_VIEW,
BaseItemKind.COLLECTION_FOLDER -> {
BaseItemKind.COLLECTION_FOLDER -> coroutineScope.launch {
val item by api.userLibraryApi.getItem(itemId = itemId)
itemLauncher.launchUserView(item)
ItemLauncher.launchUserView(item)
}
else -> navigationRepository.navigate(Destinations.itemDetails(itemId))

View File

@@ -1,15 +1,16 @@
package org.jellyfin.androidtv.data.model
import org.jellyfin.sdk.model.api.BaseItemDto
import java.time.Instant
import java.util.UUID
class DataRefreshService {
var lastDeletedItemId: UUID? = null
var lastPlayback: Instant? = null
var lastMoviePlayback: Instant? = null
var lastTvPlayback: Instant? = null
var lastLibraryChange: Instant? = null
var lastFavoriteUpdate: Instant? = null
var lastPlayback: Long = 0
var lastMoviePlayback: Long = 0
var lastTvPlayback: Long = 0
var lastMusicPlayback: Long = 0
var lastLibraryChange: Long = 0
var lastVideoQueueChange: Long = 0
var lastFavoriteUpdate: Long = 0
var lastPlayedItem: BaseItemDto? = null
}

View File

@@ -1,15 +1,18 @@
package org.jellyfin.androidtv.data.model
import org.jellyfin.sdk.model.api.ItemFilter
import org.jellyfin.apiclient.model.querying.ItemFilter
class FilterOptions {
var isFavoriteOnly = false
var isUnwatchedOnly = false
val filters: Set<ItemFilter>
get() = buildSet {
if (isFavoriteOnly) add(ItemFilter.IS_FAVORITE)
if (isUnwatchedOnly) add(ItemFilter.IS_UNPLAYED)
val filters: Array<ItemFilter>?
get() {
if (!isUnwatchedOnly && !isFavoriteOnly) return null
return buildList {
if (isFavoriteOnly) add(ItemFilter.IsFavorite)
if (isUnwatchedOnly) add(ItemFilter.IsUnplayed)
}.toTypedArray()
}
}

View File

@@ -0,0 +1,3 @@
package org.jellyfin.androidtv.data.querying;
data class AdditionalPartsQuery(val itemId: String)

View File

@@ -0,0 +1,5 @@
package org.jellyfin.androidtv.data.querying
import org.jellyfin.apiclient.model.querying.ArtistsQuery
class AlbumArtistsQuery : ArtistsQuery()

View File

@@ -1,7 +0,0 @@
package org.jellyfin.androidtv.data.querying;
import java.util.UUID
data class GetAdditionalPartsRequest(
val itemId: UUID,
)

View File

@@ -1,3 +0,0 @@
package org.jellyfin.androidtv.data.querying
data object GetSeriesTimersRequest

View File

@@ -1,7 +0,0 @@
package org.jellyfin.androidtv.data.querying
import java.util.UUID
data class GetSpecialsRequest(
val itemId: UUID,
)

View File

@@ -1,7 +0,0 @@
package org.jellyfin.androidtv.data.querying
import java.util.UUID
data class GetTrailersRequest(
val itemId: UUID,
)

View File

@@ -0,0 +1,5 @@
package org.jellyfin.androidtv.data.querying
data class SpecialsQuery(
val itemId: String,
)

View File

@@ -0,0 +1,26 @@
package org.jellyfin.androidtv.data.querying
import org.jellyfin.androidtv.auth.repository.UserRepository
import org.jellyfin.apiclient.model.querying.ItemFields
import org.jellyfin.apiclient.model.querying.ItemQuery
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
class StdItemQuery @JvmOverloads constructor(
fields: Array<ItemFields> = defaultFields
) : ItemQuery(), KoinComponent {
companion object {
val defaultFields = arrayOf(
ItemFields.PrimaryImageAspectRatio,
ItemFields.Overview,
ItemFields.ItemCounts,
ItemFields.DisplayPreferencesId,
ItemFields.ChildCount
)
}
init {
userId = get<UserRepository>().currentUser.value!!.id.toString()
setFields(fields)
}
}

View File

@@ -0,0 +1,5 @@
package org.jellyfin.androidtv.data.querying
data class TrailersQuery(
val itemId: String,
)

View File

@@ -1,3 +1,3 @@
package org.jellyfin.androidtv.data.querying
data object GetUserViewsRequest
object ViewQuery

View File

@@ -6,7 +6,6 @@ import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.UserItemDataDto
import java.time.Instant
interface ItemMutationRepository {
suspend fun setFavorite(item: UUID, favorite: Boolean): UserItemDataDto
@@ -23,7 +22,7 @@ class ItemMutationRepositoryImpl(
else -> api.userLibraryApi.unmarkFavoriteItem(itemId = item)
}
dataRefreshService.lastFavoriteUpdate = Instant.now()
dataRefreshService.lastFavoriteUpdate = System.currentTimeMillis()
return response
}

View File

@@ -1,5 +1,6 @@
package org.jellyfin.androidtv.data.repository
import android.app.UiModeManager
import android.content.Context
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -22,6 +23,7 @@ interface NotificationsRepository {
class NotificationsRepositoryImpl(
private val context: Context,
private val uiModeManager: UiModeManager,
private val systemPreferences: SystemPreferences,
) : NotificationsRepository {
override val notifications = MutableStateFlow(emptyList<AppNotification>())

View File

@@ -5,14 +5,14 @@ import kotlinx.coroutines.flow.flow
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.constant.CollectionType
interface UserViewsRepository {
val views: Flow<Collection<BaseItemDto>>
fun isSupported(collectionType: CollectionType?): Boolean
fun allowViewSelection(collectionType: CollectionType?): Boolean
fun allowGridView(collectionType: CollectionType?): Boolean
fun isSupported(collectionType: String?): Boolean
fun allowViewSelection(collectionType: String?): Boolean
fun allowGridView(collectionType: String?): Boolean
}
class UserViewsRepositoryImpl(
@@ -21,29 +21,30 @@ class UserViewsRepositoryImpl(
override val views = flow {
val views by api.userViewsApi.getUserViews()
val filteredViews = views.items
.orEmpty()
.filter { isSupported(it.collectionType) }
emit(filteredViews)
}
override fun isSupported(collectionType: CollectionType?) = collectionType !in unsupportedCollectionTypes
override fun allowViewSelection(collectionType: CollectionType?) = collectionType !in disallowViewSelectionCollectionTypes
override fun allowGridView(collectionType: CollectionType?) = collectionType !in disallowGridViewCollectionTypes
override fun isSupported(collectionType: String?) = collectionType !in unsupportedCollectionTypes
override fun allowViewSelection(collectionType: String?) = collectionType !in disallowViewSelectionCollectionTypes
override fun allowGridView(collectionType: String?) = collectionType !in disallowGridViewCollectionTypes
private companion object {
private val unsupportedCollectionTypes = arrayOf(
CollectionType.BOOKS,
CollectionType.FOLDERS
CollectionType.Books,
CollectionType.Folders
)
private val disallowViewSelectionCollectionTypes = arrayOf(
CollectionType.LIVETV,
CollectionType.MUSIC,
CollectionType.PHOTOS,
CollectionType.LiveTv,
CollectionType.Music,
CollectionType.Photos,
)
private val disallowGridViewCollectionTypes = arrayOf(
CollectionType.LIVETV,
CollectionType.MUSIC
CollectionType.LiveTv,
CollectionType.Music
)
}
}

View File

@@ -3,9 +3,9 @@ package org.jellyfin.androidtv.data.service
import android.content.Context
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import coil3.ImageLoader
import coil3.request.ImageRequest
import coil3.toBitmap
import androidx.core.graphics.drawable.toBitmap
import coil.ImageLoader
import coil.request.ImageRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
@@ -20,7 +20,6 @@ import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
import java.time.Instant
import java.util.UUID
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@@ -48,10 +47,8 @@ class BackgroundService(
private var _backgrounds = emptyList<ImageBitmap>()
private var _currentIndex = 0
private var _currentBackground = MutableStateFlow<ImageBitmap?>(null)
private var _blurBackground = MutableStateFlow(false)
private var _enabled = MutableStateFlow(true)
val currentBackground get() = _currentBackground.asStateFlow()
val blurBackground get() = _blurBackground.asStateFlow()
val enabled get() = _enabled.asStateFlow()
// Helper function for [setBackground]
@@ -79,9 +76,6 @@ class BackgroundService(
if (baseItem == null || !userPreferences[UserPreferences.backdropEnabled])
return clearBackgrounds()
// Enable blur for backdrops
_blurBackground.value = true
// Get all backdrop urls
val itemBackdropUrls = baseItem.backdropImageTags.getUrls(baseItem.id)
val parentBackdropUrls = baseItem.parentBackdropImageTags.getUrls(baseItem.parentBackdropItemId)
@@ -102,9 +96,6 @@ class BackgroundService(
if (!server.splashscreenEnabled)
return clearBackgrounds()
// Disable blur on splashscreen
_blurBackground.value = false
// Manually grab the backdrop URL
val api = jellyfin.createApi(baseUrl = server.address)
val splashscreenUrl = api.imageApi.getSplashscreenUrl()
@@ -124,7 +115,7 @@ class BackgroundService(
_backgrounds = backdropUrls.mapNotNull { url ->
imageLoader.execute(
request = ImageRequest.Builder(context).data(url).build()
).image?.toBitmap()?.asImageBitmap()
).drawable?.toBitmap()?.asImageBitmap()
}
// Go to first background
@@ -153,7 +144,7 @@ class BackgroundService(
}
internal fun update() {
val now = Instant.now().toEpochMilli()
val now = System.currentTimeMillis()
if (lastBackgroundTimerUpdate > now - TRANSITION_DURATION.inWholeMilliseconds)
return setTimer((lastBackgroundTimerUpdate - now).milliseconds + TRANSITION_DURATION, false)

View File

@@ -2,12 +2,10 @@ package org.jellyfin.androidtv.di
import android.content.Context
import android.os.Build
import coil3.ImageLoader
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.serviceLoaderEnabled
import coil3.svg.SvgDecoder
import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.auth.repository.ServerRepository
import org.jellyfin.androidtv.auth.repository.UserRepository
@@ -23,17 +21,13 @@ import org.jellyfin.androidtv.data.repository.NotificationsRepositoryImpl
import org.jellyfin.androidtv.data.repository.UserViewsRepository
import org.jellyfin.androidtv.data.repository.UserViewsRepositoryImpl
import org.jellyfin.androidtv.data.service.BackgroundService
import org.jellyfin.androidtv.integration.dream.DreamViewModel
import org.jellyfin.androidtv.ui.ScreensaverViewModel
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
import org.jellyfin.androidtv.ui.navigation.Destinations
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
import org.jellyfin.androidtv.ui.navigation.NavigationRepositoryImpl
import org.jellyfin.androidtv.ui.picture.PictureViewerViewModel
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
import org.jellyfin.androidtv.ui.playback.nextup.NextUpViewModel
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepository
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepositoryImpl
import org.jellyfin.androidtv.ui.search.SearchFragmentDelegate
import org.jellyfin.androidtv.ui.search.SearchRepository
import org.jellyfin.androidtv.ui.search.SearchRepositoryImpl
@@ -41,22 +35,19 @@ import org.jellyfin.androidtv.ui.search.SearchViewModel
import org.jellyfin.androidtv.ui.startup.ServerAddViewModel
import org.jellyfin.androidtv.ui.startup.StartupViewModel
import org.jellyfin.androidtv.ui.startup.UserLoginViewModel
import org.jellyfin.androidtv.util.KeyProcessor
import org.jellyfin.androidtv.util.MarkdownRenderer
import org.jellyfin.androidtv.util.PlaybackHelper
import org.jellyfin.androidtv.util.apiclient.ReportingHelper
import org.jellyfin.androidtv.util.sdk.SdkPlaybackHelper
import org.jellyfin.androidtv.util.sdk.legacy
import org.jellyfin.apiclient.AppInfo
import org.jellyfin.apiclient.android
import org.jellyfin.apiclient.logging.AndroidLogger
import org.jellyfin.sdk.android.androidDevice
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import org.koin.android.ext.koin.androidApplication
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.viewModel
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
import org.jellyfin.apiclient.Jellyfin as JellyfinApiClient
@@ -85,7 +76,9 @@ val appModule = module {
get<JellyfinSdk>().createApi()
}
single { SocketHandler(get(), get(), get(), get(), get(), get(), get(), get(), get()) }
single { get<ApiClient>().ws() }
single { SocketHandler(get(), get(), get(), get(), get(), get(), get(), get()) }
// Old apiclient
single {
@@ -105,10 +98,8 @@ val appModule = module {
// Coil (images)
single {
ImageLoader.Builder(androidContext()).apply {
serviceLoaderEnabled(false)
components {
add(OkHttpNetworkFetcherFactory())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) add(AnimatedImageDecoder.Factory())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) add(ImageDecoderDecoder.Factory())
else add(GifDecoder.Factory())
add(SvgDecoder.Factory())
}
@@ -121,12 +112,11 @@ val appModule = module {
single<UserRepository> { UserRepositoryImpl() }
single<UserViewsRepository> { UserViewsRepositoryImpl(get()) }
single<NotificationsRepository> { NotificationsRepositoryImpl(get(), get()) }
single<NotificationsRepository> { NotificationsRepositoryImpl(get(), get(), get()) }
single<ItemMutationRepository> { ItemMutationRepositoryImpl(get(), get()) }
single<CustomMessageRepository> { CustomMessageRepositoryImpl() }
single<NavigationRepository> { NavigationRepositoryImpl(Destinations.home) }
single<SearchRepository> { SearchRepositoryImpl(get()) }
single<MediaSegmentRepository> { MediaSegmentRepositoryImpl(get(), get()) }
viewModel { StartupViewModel(get(), get(), get(), get()) }
viewModel { UserLoginViewModel(get(), get(), get(), get(defaultDeviceInfo)) }
@@ -135,15 +125,10 @@ val appModule = module {
viewModel { PictureViewerViewModel(get()) }
viewModel { ScreensaverViewModel(get()) }
viewModel { SearchViewModel(get()) }
viewModel { DreamViewModel(get(), get(), get(), get(), get()) }
single { BackgroundService(get(), get(), get(), get(), get()) }
single { MarkdownRenderer(get()) }
single { ItemLauncher() }
single { KeyProcessor() }
single { ReportingHelper(get(), get()) }
single<PlaybackHelper> { SdkPlaybackHelper(get(), get(), get(), get(), get(), get(), get()) }
factory { (context: Context) -> SearchFragmentDelegate(context, get(), get()) }
factory { (context: Context) -> SearchFragmentDelegate(context, get()) }
}

View File

@@ -12,17 +12,15 @@ import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.preference.UserSettingPreferences
import org.jellyfin.androidtv.ui.browsing.MainActivity
import org.jellyfin.androidtv.ui.playback.GarbagePlaybackLauncher
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.androidtv.ui.playback.LegacyMediaManager
import org.jellyfin.androidtv.ui.playback.RewritePlaybackLauncher
import org.jellyfin.androidtv.ui.playback.VideoQueueManager
import org.jellyfin.androidtv.ui.playback.rewrite.RewriteMediaManager
import org.jellyfin.playback.core.mediasession.MediaSessionOptions
import org.jellyfin.playback.core.mediasession.mediaSessionPlugin
import org.jellyfin.playback.core.playbackManager
import org.jellyfin.playback.exoplayer.exoPlayerPlugin
import org.jellyfin.playback.jellyfin.jellyfinPlugin
import org.jellyfin.playback.media3.exoplayer.ExoPlayerOptions
import org.jellyfin.playback.media3.exoplayer.exoPlayerPlugin
import org.jellyfin.playback.media3.session.MediaSessionOptions
import org.jellyfin.playback.media3.session.media3SessionPlugin
import org.jellyfin.sdk.api.client.ApiClient
import org.koin.android.ext.koin.androidContext
import org.koin.core.scope.Scope
import org.koin.dsl.module
@@ -32,7 +30,16 @@ import org.jellyfin.androidtv.ui.playback.PlaybackManager as LegacyPlaybackManag
val playbackModule = module {
single { LegacyPlaybackManager(get()) }
single { VideoQueueManager() }
single<MediaManager> { RewriteMediaManager(get(), get(), get(), get()) }
single { LegacyMediaManager(get()) }
single { RewriteMediaManager(get(), get(), get(), get()) }
factory {
val preferences = get<UserPreferences>()
val useRewrite = preferences[UserPreferences.playbackRewriteAudioEnabled]
if (useRewrite) get<RewriteMediaManager>()
else get<LegacyMediaManager>()
}
factory {
val preferences = get<UserPreferences>()
@@ -46,10 +53,13 @@ val playbackModule = module {
}
fun Scope.createPlaybackManager() = playbackManager(androidContext()) {
install(exoPlayerPlugin(get()))
install(jellyfinPlugin(get(), get()))
val activityIntent = Intent(get(), MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(get(), 0, activityIntent, PendingIntent.FLAG_IMMUTABLE)
val notificationChannelId = "session"
val notificationChannelId = "mediasession"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
notificationChannelId,
@@ -59,25 +69,12 @@ fun Scope.createPlaybackManager() = playbackManager(androidContext()) {
NotificationManagerCompat.from(get()).createNotificationChannel(channel)
}
val userPreferences = get<UserPreferences>()
val api = get<ApiClient>()
val exoPlayerOptions = ExoPlayerOptions(
httpConnectTimeout = api.httpClientOptions.connectTimeout,
httpReadTimeout = api.httpClientOptions.requestTimeout,
preferFfmpeg = userPreferences[UserPreferences.preferExoPlayerFfmpeg],
enableDebugLogging = userPreferences[UserPreferences.debuggingEnabled],
)
install(exoPlayerPlugin(get(), exoPlayerOptions))
val mediaSessionOptions = MediaSessionOptions(
install(mediaSessionPlugin(get(), MediaSessionOptions(
channelId = notificationChannelId,
notificationId = 1,
iconSmall = R.drawable.app_icon_foreground,
openIntent = pendingIntent,
)
install(media3SessionPlugin(get(), mediaSessionOptions))
install(jellyfinPlugin(get()))
)))
// Options
val userSettingPreferences = get<UserSettingPreferences>()

View File

@@ -1,8 +1,10 @@
package org.jellyfin.androidtv.di
import org.jellyfin.androidtv.util.AutoBitrate
import org.jellyfin.androidtv.util.ImageHelper
import org.koin.dsl.module
val utilsModule = module {
single { AutoBitrate(get()) }
single { ImageHelper(get()) }
}

View File

@@ -25,10 +25,9 @@ import org.jellyfin.androidtv.data.repository.UserViewsRepository
import org.jellyfin.androidtv.integration.provider.ImageProvider
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.ui.startup.StartupActivity
import org.jellyfin.androidtv.util.ImageHelper
import org.jellyfin.androidtv.util.ImageUtils
import org.jellyfin.androidtv.util.dp
import org.jellyfin.androidtv.util.sdk.isUsable
import org.jellyfin.androidtv.util.stripHtml
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.exception.TimeoutException
@@ -42,12 +41,11 @@ import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageFormat
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.constant.MediaType
import org.jellyfin.sdk.model.extensions.ticks
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import timber.log.Timber
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import kotlin.time.Duration
@@ -69,7 +67,6 @@ class LeanbackChannelWorker(
private val api by inject<ApiClient>()
private val userPreferences by inject<UserPreferences>()
private val userViewsRepository by inject<UserViewsRepository>()
private val imageHelper by inject<ImageHelper>()
/**
* Check if the app can use Leanback features and is API level 26 or higher.
@@ -221,6 +218,7 @@ class LeanbackChannelWorker(
// Add new items
return response.items
.orEmpty()
.filter { userViewsRepository.isSupported(it.collectionType) }
}
@@ -258,7 +256,7 @@ class LeanbackChannelWorker(
tag = imageTags?.get(ImageType.PRIMARY),
)
else -> imageHelper.getResourceUrl(context, R.drawable.tile_land_tv)
else -> ImageUtils.getResourceUrl(context, R.drawable.tile_land_tv)
}.let(ImageProvider::getImageUri)
/**
@@ -273,19 +271,19 @@ class LeanbackChannelWorker(
fields = listOf(ItemFields.DATE_CREATED),
imageTypeLimit = 1,
limit = 10,
mediaTypes = listOf(MediaType.VIDEO),
mediaTypes = listOf(MediaType.Video),
includeItemTypes = listOf(BaseItemKind.EPISODE, BaseItemKind.MOVIE),
excludeActiveSessions = true,
).content.items
).content.items.orEmpty()
}
val nextUp = async {
api.tvShowsApi.getNextUp(
userId = api.userId,
imageTypeLimit = 1,
limit = 10,
enableResumable = false,
fields = listOf(ItemFields.DATE_CREATED),
).content.items
).content.items.orEmpty()
}
// Concat
@@ -363,7 +361,7 @@ class LeanbackChannelWorker(
.setEpisodeTitle(if (item.type == BaseItemKind.EPISODE) item.name else null)
.setSeasonNumber(seasonString, item.parentIndexNumber ?: 0)
.setEpisodeNumber(episodeString, item.indexNumber ?: 0)
.setDescription(item.overview?.stripHtml())
.setDescription(item.overview)
.setReleaseDate(
if (item.premiereDate != null) DateTimeFormatter.ISO_DATE.format(item.premiereDate)
else null
@@ -457,7 +455,7 @@ class LeanbackChannelWorker(
setLastEngagementTimeUtcMillis(
engagement?.toInstant(ZoneOffset.UTC)?.toEpochMilli()
?: Instant.now().toEpochMilli()
?: System.currentTimeMillis()
)
// Episode runtime has been determined

View File

@@ -13,7 +13,7 @@ import kotlinx.coroutines.runBlocking
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.integration.provider.ImageProvider
import org.jellyfin.androidtv.util.ImageHelper
import org.jellyfin.androidtv.util.ImageUtils
import org.jellyfin.androidtv.util.sdk.isUsable
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
@@ -41,7 +41,6 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
}
private val api by inject<ApiClient>()
private val imageHelper by inject<ImageHelper>()
override fun onCreate(): Boolean = api.isUsable
@@ -71,7 +70,7 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
* Gets the resumable items or returns null
*/
private suspend fun searchItems(query: String, limit: Int): BaseItemDtoQueryResult? = try {
val items by api.itemsApi.getItems(
val items by api.itemsApi.getItemsByUserId(
searchTerm = query,
recursive = true,
limit = limit,
@@ -107,7 +106,7 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
val imageUri = if (item.imageTags?.contains(ImageType.PRIMARY) == true)
ImageProvider.getImageUri(api.imageApi.getItemImageUrl(item.id, ImageType.PRIMARY))
else
imageHelper.getResourceUrl(context!!, R.drawable.tile_land_tv)
ImageUtils.getResourceUrl(context, R.drawable.tile_land_tv)
cursor.newRow().apply {
add(BaseColumns._ID, item.id)

View File

@@ -4,19 +4,15 @@ import android.service.dreams.DreamService
import androidx.annotation.CallSuper
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
abstract class DreamServiceCompat : DreamService(), SavedStateRegistryOwner, ViewModelStoreOwner {
abstract class DreamServiceCompat : DreamService(), SavedStateRegistryOwner {
@Suppress("LeakingThis")
private val lifecycleRegistry = LifecycleRegistry(this)
@@ -26,7 +22,6 @@ abstract class DreamServiceCompat : DreamService(), SavedStateRegistryOwner, Vie
}
override val lifecycle: Lifecycle get() = lifecycleRegistry
override val viewModelStore = ViewModelStore()
override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
@CallSuper
@@ -51,12 +46,9 @@ abstract class DreamServiceCompat : DreamService(), SavedStateRegistryOwner, Vie
fun setContent(content: @Composable () -> Unit) {
val view = ComposeView(this)
// Set composition strategy
view.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
// Inject dependencies normally added by appcompat activities
view.setViewTreeLifecycleOwner(this)
view.setViewTreeViewModelStoreOwner(this)
view.setViewTreeSavedStateRegistryOwner(this)
// Set content composable

View File

@@ -1,148 +0,0 @@
package org.jellyfin.androidtv.integration.dream
import android.annotation.SuppressLint
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import coil3.ImageLoader
import coil3.request.ImageRequest
import coil3.toBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.integration.dream.model.DreamContent
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.playback.core.PlaybackManager
import org.jellyfin.playback.core.queue.queue
import org.jellyfin.playback.jellyfin.queue.baseItem
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageFormat
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
@SuppressLint("StaticFieldLeak")
class DreamViewModel(
private val api: ApiClient,
private val imageLoader: ImageLoader,
private val context: Context,
playbackManager: PlaybackManager,
private val userPreferences: UserPreferences,
) : ViewModel() {
@OptIn(ExperimentalCoroutinesApi::class)
private val _mediaContent = playbackManager.queue.entry
.map { entry ->
entry?.baseItem?.let { baseItem ->
DreamContent.NowPlaying(entry, baseItem)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
private val _libraryContent = flow {
// Load first library item after 2 seconds
// to force the logo at the start of the screensaver
emit(null)
delay(2.seconds)
while (true) {
val next = getRandomLibraryShowcase()
if (next != null) {
emit(next)
delay(30.seconds)
} else {
delay(3.seconds)
}
}
}
.distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
val content = combine(_mediaContent, _libraryContent) { mediaContent, libraryContent ->
mediaContent ?: libraryContent ?: DreamContent.Logo
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = _mediaContent.value ?: _libraryContent.value ?: DreamContent.Logo,
)
private suspend fun getRandomLibraryShowcase(): DreamContent.LibraryShowcase? {
val requireParentalRating = userPreferences[UserPreferences.screensaverAgeRatingRequired]
val maxParentalRating = userPreferences[UserPreferences.screensaverAgeRatingMax]
try {
val response by api.itemsApi.getItems(
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
recursive = true,
sortBy = listOf(ItemSortBy.RANDOM),
limit = 5,
imageTypes = listOf(ImageType.BACKDROP),
maxOfficialRating = if (maxParentalRating == -1) null else maxParentalRating.toString(),
hasParentalRating = if (requireParentalRating) true else null,
)
val item = response.items.firstOrNull { item ->
!item.backdropImageTags.isNullOrEmpty()
} ?: return null
Timber.i("Loading random library showcase item ${item.id}")
val backdropTag = item.backdropImageTags!!.randomOrNull()
?: item.imageTags?.get(ImageType.BACKDROP)
val logoTag = item.imageTags?.get(ImageType.LOGO)
val backdropUrl = api.imageApi.getItemImageUrl(
itemId = item.id,
imageType = ImageType.BACKDROP,
tag = backdropTag,
format = ImageFormat.WEBP,
)
val logoUrl = api.imageApi.getItemImageUrl(
itemId = item.id,
imageType = ImageType.LOGO,
tag = logoTag,
format = ImageFormat.WEBP,
)
val (logo, backdrop) = withContext(Dispatchers.IO) {
val logoDeferred = async {
imageLoader.execute(
request = ImageRequest.Builder(context).data(logoUrl).build()
).image?.toBitmap()
}
val backdropDeferred = async {
imageLoader.execute(
request = ImageRequest.Builder(context).data(backdropUrl).build()
).image?.toBitmap()
}
awaitAll(logoDeferred, backdropDeferred)
}
if (backdrop == null) {
return null
}
return DreamContent.LibraryShowcase(item, backdrop, logo)
} catch (err: ApiClientException) {
Timber.e(err)
return null
}
}
}

View File

@@ -4,7 +4,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -12,12 +12,10 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import org.jellyfin.androidtv.integration.dream.model.DreamContent
import org.jellyfin.androidtv.ui.composable.ZoomBox
import org.jellyfin.androidtv.ui.composable.modifier.overscan
import org.jellyfin.androidtv.ui.composable.overscan
@Composable
fun DreamContentLibraryShowcase(
@@ -49,21 +47,12 @@ fun DreamContentLibraryShowcase(
.align(Alignment.BottomStart)
.overscan(),
) {
if (content.logo != null) {
Image(
bitmap = content.logo.asImageBitmap(),
contentDescription = content.item.name,
modifier = Modifier
.height(75.dp)
)
} else {
Text(
text = content.item.name.orEmpty(),
style = TextStyle(
color = Color.White,
fontSize = 32.sp
),
)
}
Text(
text = content.item.name.orEmpty(),
style = TextStyle(
color = Color.White,
fontSize = 32.sp
),
)
}
}

View File

@@ -13,36 +13,38 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import org.jellyfin.androidtv.integration.dream.model.DreamContent
import org.jellyfin.androidtv.ui.composable.AsyncImage
import org.jellyfin.androidtv.ui.composable.LyricsDtoBox
import org.jellyfin.androidtv.ui.composable.blurHashPainter
import org.jellyfin.androidtv.ui.composable.modifier.fadingEdges
import org.jellyfin.androidtv.ui.composable.modifier.overscan
import org.jellyfin.androidtv.ui.composable.rememberPlayerProgress
import org.jellyfin.playback.core.PlaybackManager
import org.jellyfin.playback.core.model.PlayState
import org.jellyfin.playback.jellyfin.lyrics
import org.jellyfin.playback.jellyfin.lyricsFlow
import org.jellyfin.androidtv.ui.composable.overscan
import org.jellyfin.androidtv.ui.playback.AudioEventListener
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.ImageFormat
import org.jellyfin.sdk.model.api.ImageType
import org.koin.compose.koinInject
import org.jellyfin.sdk.model.extensions.ticks
import org.koin.compose.rememberKoinInject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun DreamContentNowPlaying(
@@ -50,22 +52,18 @@ fun DreamContentNowPlaying(
) = Box(
modifier = Modifier.fillMaxSize(),
) {
val api = koinInject<ApiClient>()
val playbackManager = koinInject<PlaybackManager>()
val lyrics = content.entry.run { lyricsFlow.collectAsState(lyrics) }.value
val progress = rememberPlayerProgress(playbackManager)
val api = rememberKoinInject<ApiClient>()
val mediaManager = rememberKoinInject<MediaManager>()
val item = content.item ?: return@Box
val primaryImageTag = content.item.imageTags?.get(ImageType.PRIMARY)
val primaryImageTag = item.imageTags?.get(ImageType.PRIMARY)
val (imageItemId, imageTag) = when {
primaryImageTag != null -> content.item.id to primaryImageTag
(content.item.albumId != null && content.item.albumPrimaryImageTag != null) -> content.item.albumId to content.item.albumPrimaryImageTag
primaryImageTag != null -> item.id to primaryImageTag
(item.albumId != null && item.albumPrimaryImageTag != null) -> item.albumId to item.albumPrimaryImageTag
else -> null to null
}
// Background
val imageBlurHash = imageTag?.let { tag ->
content.item.imageBlurHashes?.get(ImageType.PRIMARY)?.get(tag)
}
val imageBlurHash = imageTag?.let { tag -> item.imageBlurHashes?.get(ImageType.PRIMARY)?.get(tag) }
if (imageBlurHash != null) {
Image(
painter = blurHashPainter(imageBlurHash, IntSize(32, 32)),
@@ -78,24 +76,7 @@ fun DreamContentNowPlaying(
DreamContentVignette()
}
// Lyrics overlay (on top of background)
if (lyrics != null) {
val playState by playbackManager.state.playState.collectAsState()
LyricsDtoBox(
lyricDto = lyrics,
currentTimestamp = playbackManager.state.positionInfo.active,
duration = playbackManager.state.positionInfo.duration,
paused = playState != PlayState.PLAYING,
fontSize = 22.sp,
color = Color.White,
modifier = Modifier
.fillMaxSize()
.fadingEdges(vertical = 250.dp)
.padding(horizontal = 50.dp),
)
}
// Metadata overlay (includes title / progress)
// Overlay
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(20.dp),
@@ -124,7 +105,7 @@ fun DreamContentNowPlaying(
.padding(bottom = 10.dp)
) {
Text(
text = content.item.name.orEmpty(),
text = item.name.orEmpty(),
style = TextStyle(
color = Color.White,
fontSize = 26.sp,
@@ -132,16 +113,12 @@ fun DreamContentNowPlaying(
)
Text(
text = content.item.run {
val artistNames = artists.orEmpty()
val albumArtistNames = albumArtists?.mapNotNull { it.name }.orEmpty()
when {
artistNames.isNotEmpty() -> artistNames
albumArtistNames.isNotEmpty() -> albumArtistNames
else -> listOfNotNull(albumArtist)
}.joinToString(", ")
},
text = item.run {
if (!artists.isNullOrEmpty()) return@run artists?.joinToString(", ")
val albumArtistNames = albumArtists?.mapNotNull { it.name }
if (!albumArtistNames.isNullOrEmpty()) return@run albumArtistNames.joinToString(", ")
return@run albumArtist
}.orEmpty(),
style = TextStyle(
color = Color(0.8f, 0.8f, 0.8f),
fontSize = 18.sp,
@@ -150,20 +127,28 @@ fun DreamContentNowPlaying(
Spacer(modifier = Modifier.height(10.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.drawWithContent {
// Background
drawRect(Color.White, alpha = 0.2f)
// Foreground
drawRect(
Color.White,
size = size.copy(width = progress * size.width)
)
var progress by remember { mutableFloatStateOf(0f) }
DisposableEffect(Unit) {
val listener = object : AudioEventListener {
override fun onProgress(pos: Long) {
val duration = item.runTimeTicks?.ticks ?: Duration.ZERO
progress = (pos.milliseconds / duration).toFloat()
}
}
mediaManager.addAudioEventListener(listener)
onDispose {
mediaManager.removeAudioEventListener(listener)
}
}
LinearProgressIndicator(
progress = progress,
color = Color.White,
backgroundColor = Color.White.copy(alpha = 0.2f),
strokeCap = StrokeCap.Round,
modifier = Modifier.fillMaxWidth()
)
}
}

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -19,9 +20,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.composable.modifier.overscan
import org.jellyfin.androidtv.ui.composable.overscan
import org.jellyfin.androidtv.ui.composable.rememberCurrentTime
@Composable

View File

@@ -1,25 +1,111 @@
package org.jellyfin.androidtv.integration.dream.composable
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import org.jellyfin.androidtv.integration.dream.DreamViewModel
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.graphics.drawable.toBitmap
import coil.ImageLoader
import coil.request.ImageRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.integration.dream.model.DreamContent
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.preference.constant.ClockBehavior
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import org.jellyfin.androidtv.ui.composable.rememberMediaItem
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageFormat
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.constant.ItemSortBy
import org.koin.compose.rememberKoinInject
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
@Composable
fun DreamHost() {
val viewModel = koinViewModel<DreamViewModel>()
val userPreferences = koinInject<UserPreferences>()
val content by viewModel.content.collectAsState()
val api = rememberKoinInject<ApiClient>()
val userPreferences = rememberKoinInject<UserPreferences>()
val mediaManager = rememberKoinInject<MediaManager>()
val imageLoader = rememberKoinInject<ImageLoader>()
val context = LocalContext.current
var libraryShowcase by remember { mutableStateOf<DreamContent.LibraryShowcase?>(null) }
val mediaItem by rememberMediaItem(mediaManager)
LaunchedEffect(true) {
delay(2.seconds)
while (true) {
libraryShowcase = getRandomLibraryShowcase(api, imageLoader, context)
delay(30.seconds)
}
}
DreamView(
content = content,
content = when {
mediaItem != null -> DreamContent.NowPlaying(mediaItem)
libraryShowcase != null -> libraryShowcase!!
else -> DreamContent.Logo
},
showClock = when (userPreferences[UserPreferences.clockBehavior]) {
ClockBehavior.ALWAYS, ClockBehavior.IN_MENUS -> true
else -> false
}
)
}
private suspend fun getRandomLibraryShowcase(
api: ApiClient,
imageLoader: ImageLoader,
context: Context
): DreamContent.LibraryShowcase? {
try {
val response by api.itemsApi.getItemsByUserId(
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
recursive = true,
sortBy = listOf(ItemSortBy.Random),
limit = 5,
imageTypes = listOf(ImageType.BACKDROP),
// TODO: Add preferences for these two settings
maxOfficialRating = "PG-13",
// hasParentalRating = true,
)
val item = response.items?.firstOrNull { item ->
!item.backdropImageTags.isNullOrEmpty()
} ?: return null
Timber.i("Loading random library showcase item ${item.id}")
val tag = item.backdropImageTags!!.randomOrNull()
?: item.imageTags?.get(ImageType.BACKDROP)
val backdropUrl = api.imageApi.getItemImageUrl(
itemId = item.id,
imageType = ImageType.BACKDROP,
tag = tag,
format = ImageFormat.WEBP,
)
val backdrop = withContext(Dispatchers.IO) {
imageLoader.execute(
request = ImageRequest.Builder(context).data(backdropUrl).build()
).drawable?.toBitmap()
} ?: return null
return DreamContent.LibraryShowcase(item, backdrop)
} catch (err: ApiClientException) {
Timber.e(err)
return null
}
}

View File

@@ -1,6 +1,7 @@
package org.jellyfin.androidtv.integration.dream.composable
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@@ -12,6 +13,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.jellyfin.androidtv.integration.dream.model.DreamContent
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun DreamView(
content: DreamContent,

View File

@@ -1,11 +1,10 @@
package org.jellyfin.androidtv.integration.dream.model
import android.graphics.Bitmap
import org.jellyfin.playback.core.queue.QueueEntry
import org.jellyfin.sdk.model.api.BaseItemDto
sealed interface DreamContent {
data object Logo : DreamContent
data class LibraryShowcase(val item: BaseItemDto, val backdrop: Bitmap, val logo: Bitmap?) : DreamContent
data class NowPlaying(val entry: QueueEntry, val item: BaseItemDto) : DreamContent
data class LibraryShowcase(val item: BaseItemDto, val backdrop: Bitmap) : DreamContent
data class NowPlaying(val item: BaseItemDto?) : DreamContent
}

View File

@@ -9,10 +9,8 @@ import android.os.Build
import android.os.ParcelFileDescriptor
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import coil3.ImageLoader
import coil3.asDrawable
import coil3.request.ImageRequest
import coil3.request.error
import coil.ImageLoader
import coil.request.ImageRequest
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.R
import org.koin.android.ext.android.inject
@@ -39,8 +37,8 @@ class ImageProvider : ContentProvider() {
data(src)
error(R.drawable.placeholder_icon)
target(
onSuccess = { image -> writeDrawable(image.asDrawable(context!!.resources), outputStream) },
onError = { image -> writeDrawable(requireNotNull(image?.asDrawable(context!!.resources)), outputStream) }
onSuccess = { drawable -> writeDrawable(drawable, outputStream) },
onError = { drawable -> writeDrawable(requireNotNull(drawable), outputStream) }
)
}.build())

View File

@@ -6,9 +6,10 @@ import org.jellyfin.androidtv.constant.PosterSize
import org.jellyfin.androidtv.preference.store.DisplayPreferencesStore
import org.jellyfin.preference.booleanPreference
import org.jellyfin.preference.enumPreference
import org.jellyfin.preference.stringPreference
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.constant.ItemSortBy
class LibraryPreferences(
displayPreferencesId: String,
@@ -28,7 +29,7 @@ class LibraryPreferences(
val filterUnwatchedOnly = booleanPreference("FilterUnwatchedOnly", false)
// Item sorting
val sortBy = enumPreference("SortBy", ItemSortBy.SORT_NAME)
val sortBy = stringPreference("SortBy", ItemSortBy.SortName)
val sortOrder = enumPreference("SortOrder", SortOrder.ASCENDING)
}
}

View File

@@ -4,7 +4,7 @@ import org.jellyfin.androidtv.preference.store.DisplayPreferencesStore
import org.jellyfin.preference.booleanPreference
import org.jellyfin.preference.stringPreference
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.constant.ItemSortBy
class LiveTvPreferences(
api: ApiClient,
@@ -13,7 +13,7 @@ class LiveTvPreferences(
api = api,
) {
companion object {
val channelOrder = stringPreference("livetv-channelorder", ItemSortBy.DATE_PLAYED.name)
val channelOrder = stringPreference("livetv-channelorder", ItemSortBy.DatePlayed)
val colorCodeGuide = booleanPreference("guide-colorcodedbackgrounds", false)
val favsAtTop = booleanPreference("livetv-favoritechannelsattop", true)
val showHDIndicator = booleanPreference("guide-indicator-hd", false)

View File

@@ -1,7 +1,9 @@
package org.jellyfin.androidtv.preference
import android.content.Context
import org.jellyfin.androidtv.preference.constant.PreferredVideoPlayer
import org.jellyfin.preference.booleanPreference
import org.jellyfin.preference.enumPreference
import org.jellyfin.preference.store.SharedPreferenceStore
import org.jellyfin.preference.stringPreference
@@ -58,6 +60,11 @@ class SystemPreferences(context: Context) : SharedPreferenceStore(
val liveTvGuideFilterSports = booleanPreference("guide_filter_sports", false)
// Other persistent variables
/**
* Chosen player for play with button. Changes every time user chooses a player with "play with" button.
*/
var chosenPlayer = enumPreference("chosen_player", PreferredVideoPlayer.VLC)
/**
* The version name for the latest dismissed beta notification or empty if none.
*/

View File

@@ -4,23 +4,20 @@ import android.content.Context
import android.view.KeyEvent
import androidx.preference.PreferenceManager
import org.jellyfin.androidtv.preference.constant.AppTheme
import org.jellyfin.androidtv.preference.constant.AudioBehavior
import org.jellyfin.androidtv.preference.constant.ClockBehavior
import org.jellyfin.androidtv.preference.constant.NextUpBehavior
import org.jellyfin.androidtv.preference.constant.PreferredVideoPlayer
import org.jellyfin.androidtv.preference.constant.RatingType
import org.jellyfin.androidtv.preference.constant.RefreshRateSwitchingBehavior
import org.jellyfin.androidtv.preference.constant.WatchedIndicatorBehavior
import org.jellyfin.androidtv.preference.constant.ZoomMode
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentAction
import org.jellyfin.androidtv.ui.playback.segment.toMediaSegmentActionsString
import org.jellyfin.androidtv.preference.constant.defaultAudioBehavior
import org.jellyfin.androidtv.util.DeviceUtils
import org.jellyfin.preference.booleanPreference
import org.jellyfin.preference.enumPreference
import org.jellyfin.preference.floatPreference
import org.jellyfin.preference.intPreference
import org.jellyfin.preference.longPreference
import org.jellyfin.preference.store.SharedPreferenceStore
import org.jellyfin.preference.stringPreference
import org.jellyfin.sdk.model.api.MediaSegmentType
import kotlin.time.Duration.Companion.minutes
/**
@@ -33,6 +30,11 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
) {
companion object {
/**
* The value used for automatic detection in [maxBitrate].
*/
const val MAX_BITRATE_AUTO = "0"
/* Display */
/**
* Select the app theme
@@ -50,13 +52,14 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
var premieresEnabled = booleanPreference("pref_enable_premieres", false)
/**
* Enable management of media like deleting items when the user has sufficient permissions.
* Enable management of media like deleting items when the user has sufficient permisisons.
*/
var mediaManagementEnabled = booleanPreference("enable_media_management", false)
/* Playback - General*/
/**
* Maximum bitrate in megabit for playback.
* Maximum bitrate in megabit for playback. A value of [MAX_BITRATE_AUTO] is used when
* the bitrate should be automatically detected.
*/
var maxBitrate = stringPreference("pref_max_bitrate", "100")
@@ -88,35 +91,45 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
/* Playback - Video */
/**
* Whether to use an external playback application or not.
* Preferred video player.
*/
var useExternalPlayer = booleanPreference("external_player", false)
var videoPlayer = enumPreference("video_player", PreferredVideoPlayer.EXOPLAYER)
/**
* Change refresh rate to match media when device supports it
*/
var refreshRateSwitchingBehavior = enumPreference("refresh_rate_switching_behavior", RefreshRateSwitchingBehavior.DISABLED)
/**
* Whether ExoPlayer should prefer FFmpeg renderers to core ones.
*/
var preferExoPlayerFfmpeg = booleanPreference("exoplayer_prefer_ffmpeg", defaultValue = false)
/* Playback - Audio related */
/**
* Preferred behavior for audio streaming.
*/
var audioBehaviour = enumPreference("audio_behavior", AudioBehavior.DIRECT_STREAM)
var audioBehaviour = enumPreference("audio_behavior", defaultAudioBehavior)
/**
* Preferred behavior for audio streaming.
*/
var audioNightMode = enumPreference("audio_night_mode", false)
/**
* Enable DTS
*/
var dtsEnabled = booleanPreference("pref_bitstream_dts", false)
/**
* Enable AC3
*/
var ac3Enabled = booleanPreference("pref_bitstream_ac3", true)
var ac3Enabled = booleanPreference("pref_bitstream_ac3", !DeviceUtils.isFireTvStickGen1)
/**
* Default audio delay in milliseconds for libVLC
*/
var libVLCAudioDelay = intPreference("libvlc_audio_delay", 0)
/**
* Use playback rewrite module for audio
*/
var playbackRewriteAudioEnabled = booleanPreference("playback_new_audio", true)
/* Live TV */
/**
@@ -124,6 +137,11 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
*/
var liveTvDirectPlayEnabled = booleanPreference("pref_live_direct", true)
/**
* Preferred video player for live TV
*/
var liveTvVideoPlayer = enumPreference("live_tv_video_player", PreferredVideoPlayer.AUTO)
/**
* Shortcut used for changing the audio track
*/
@@ -166,25 +184,30 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
var seriesThumbnailsEnabled = booleanPreference("pref_enable_series_thumbnails", true)
/**
* Subtitles foreground color
* Enable subtitles background
*/
var subtitlesBackgroundColor = longPreference("subtitles_background_color", 0x00FFFFFF)
var subtitlesBackgroundEnabled = booleanPreference("subtitles_background_enabled", true)
/**
* Subtitles font size
*/
var subtitlesSize = intPreference("subtitles_size", 28)
/**
* Subtitles stroke size
*/
var subtitleStrokeSize = intPreference("subtitles_stroke_size", 0)
/**
* Subtitles position
*/
var subtitlePosition = intPreference("subtitles_position", 40)
/**
* Subtitles foreground color
*/
var subtitlesTextColor = longPreference("subtitles_text_color", 0xFFFFFFFF)
/**
* Subtitles stroke color
*/
var subtitleTextStrokeColor = longPreference("subtitles_text_stroke_color", 0xFF000000)
/**
* Subtitles font size
*/
var subtitlesTextSize = floatPreference("subtitles_text_size", 1f)
/**
* Show screensaver in app
*/
@@ -196,40 +219,9 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
var screensaverInAppTimeout = longPreference("screensaver_inapp_timeout", 5.minutes.inWholeMilliseconds)
/**
* Age rating used to filter items in the screensaver. Use -1 to disable (omits parameter from requests).
* Enable reactive homepage
*/
var screensaverAgeRatingMax = intPreference("screensaver_agerating_max", 13)
/**
* Whether items shown in the screensaver are required to have an age rating set.
*/
var screensaverAgeRatingRequired = booleanPreference("screensaver_agerating_required", true)
/**
* Delay when starting video playback after loading the video player.
*/
var videoStartDelay = longPreference("video_start_delay", 0)
/**
* The actions to take for each media segment type. Managed by the [MediaSegmentRepository].
*/
var mediaSegmentActions = stringPreference(
key = "media_segment_actions",
defaultValue = mapOf(
MediaSegmentType.INTRO to MediaSegmentAction.ASK_TO_SKIP,
MediaSegmentType.OUTRO to MediaSegmentAction.ASK_TO_SKIP,
).toMediaSegmentActionsString()
)
/**
* Preferred behavior for player aspect ratio (zoom mode).
*/
var playerZoomMode = enumPreference("player_zoom_mode", ZoomMode.FIT)
/**
* Enable TrickPlay in legacy player user interface while seeking.
*/
var trickPlayEnabled = booleanPreference("trick_play_enabled", false)
var homeReactive = booleanPreference("home_reactive", false)
}
init {
@@ -242,17 +234,6 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
// Enable playback rewrite for music
putBoolean("playback_new_audio", true)
}
// v0.17.z to v0.18.0
migration(toVersion = 8) {
// Set subtitle background color to black if it was enabled in a previous version
val subtitlesBackgroundEnabled = it.getBoolean("subtitles_background_enabled", true)
putLong("subtitles_background_color", if (subtitlesBackgroundEnabled) 0XFF000000L else 0X00FFFFFFL)
// Set subtitle text stroke color to black if it was enabled in a previous version
val subtitleStrokeSize = it.getInt("subtitles_stroke_size", 0)
putLong("subtitles_text_stroke_color", if (subtitleStrokeSize > 0) 0XFF000000L else 0X00FFFFFFL)
}
}
}
}

View File

@@ -24,26 +24,10 @@ class UserSettingPreferences(
val homesection4 = enumPreference("homesection4", HomeSectionType.LIVE_TV)
val homesection5 = enumPreference("homesection5", HomeSectionType.NEXT_UP)
val homesection6 = enumPreference("homesection6", HomeSectionType.LATEST_MEDIA)
val homesection7 = enumPreference("homesection7", HomeSectionType.NONE)
val homesection8 = enumPreference("homesection8", HomeSectionType.NONE)
val homesection9 = enumPreference("homesection9", HomeSectionType.NONE)
}
val homesections = listOf(
homesection0,
homesection1,
homesection2,
homesection3,
homesection4,
homesection5,
homesection6,
homesection7,
homesection8,
homesection9,
)
val activeHomesections
get() = homesections
val homesections
get() = listOf(homesection0, homesection1, homesection2, homesection3, homesection4, homesection5, homesection6)
.map(::get)
.filterNot { it == HomeSectionType.NONE }
}

View File

@@ -1,6 +1,7 @@
package org.jellyfin.androidtv.preference.constant
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.util.DeviceUtils
import org.jellyfin.preference.PreferenceEnum
enum class AudioBehavior(
@@ -16,3 +17,6 @@ enum class AudioBehavior(
*/
DOWNMIX_TO_STEREO(R.string.pref_audio_compat),
}
val defaultAudioBehavior = if (DeviceUtils.isChromecastWithGoogleTV) AudioBehavior.DOWNMIX_TO_STEREO
else AudioBehavior.DIRECT_STREAM

View File

@@ -2,17 +2,17 @@ package org.jellyfin.androidtv.preference.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.constant.ItemSortBy
enum class LiveTvChannelOrder(
override val nameRes: Int,
val stringValue: String,
) : PreferenceEnum {
LAST_PLAYED(R.string.lbl_guide_option_played, ItemSortBy.DATE_PLAYED.serialName),
CHANNEL_NUMBER(R.string.lbl_guide_option_number, ItemSortBy.SORT_NAME.serialName);
LAST_PLAYED(R.string.lbl_guide_option_played, ItemSortBy.DatePlayed),
CHANNEL_NUMBER(R.string.lbl_guide_option_number, ItemSortBy.SortName);
companion object {
fun fromString(value: String) = entries
fun fromString(value: String) = values()
.firstOrNull { it.stringValue.equals(value, ignoreCase = true) }
?: LAST_PLAYED
}

View File

@@ -0,0 +1,33 @@
package org.jellyfin.androidtv.preference.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
enum class PreferredVideoPlayer(
override val nameRes: Int,
) : PreferenceEnum {
/**
* Automatically selects between exoplayer and vlc
*/
AUTO(R.string.pref_video_player_auto),
/**
* Force ExoPlayer
*/
EXOPLAYER(R.string.pref_video_player_exoplayer),
/**
* Force libVLC
*/
VLC(R.string.pref_video_player_vlc),
/**
* Use external player
*/
EXTERNAL(R.string.pref_video_player_external),
/**
* Choose a player - play with button
*/
CHOOSE(R.string.pref_video_player_choose),
}

View File

@@ -19,7 +19,7 @@ enum class WatchedIndicatorBehavior(
/**
* Hide unwatched count indicator, show watched check mark on individual episodes only.
*/
EPISODES_ONLY(R.string.lbl_hide_watched_checkmark),
EPISODES_ONLY(R.string.lbl_episode_marks),
/**
* Never show watched indicators.

View File

@@ -1,24 +0,0 @@
package org.jellyfin.androidtv.preference.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
enum class ZoomMode(
override val nameRes: Int,
) : PreferenceEnum {
/**
* Sets the zoom mode to normal (fit).
*/
FIT(R.string.lbl_fit),
/**
* Sets the zoom mode to auto crop.
*/
AUTO_CROP(R.string.lbl_auto_crop),
/**
* Sets the zoom mode to stretch.
*/
STRETCH(R.string.lbl_stretch),
}

View File

@@ -82,9 +82,6 @@ abstract class DisplayPreferencesStore(
override fun getLong(key: String, defaultValue: Long) =
cachedPreferences[key]?.toLongOrNull() ?: defaultValue
override fun getFloat(key: String, defaultValue: Float) =
cachedPreferences[key]?.toFloatOrNull() ?: defaultValue
override fun getBool(key: String, defaultValue: Boolean) =
cachedPreferences[key]?.toBooleanStrictOrNull() ?: defaultValue
@@ -99,10 +96,6 @@ abstract class DisplayPreferencesStore(
cachedPreferences[key] = value.toString()
}
override fun setFloat(key: String, value: Float) {
cachedPreferences[key] = value.toString()
}
override fun setBool(key: String, value: Boolean) {
cachedPreferences[key] = value.toString()
}
@@ -126,8 +119,8 @@ abstract class DisplayPreferencesStore(
override fun <V : Enum<V>> setEnum(preference: Preference<*>, value: Enum<V>) =
setString(
preference.key, when (value) {
is PreferenceEnum -> value.serializedName ?: value.name
else -> value.name
is PreferenceEnum -> value.serializedName ?: value.toString()
else -> value.toString()
}
)

View File

@@ -9,18 +9,14 @@ import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.doOnAttach
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
import coil3.ImageLoader
import coil3.asImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import coil3.request.target
import coil3.request.transformations
import coil3.transform.CircleCropTransformation
import coil.ImageLoader
import coil.request.ImageRequest
import coil.transform.CircleCropTransformation
import com.vanniktech.blurhash.BlurHash
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.util.BlurHashDecoder
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.math.round
@@ -69,7 +65,7 @@ class AsyncImageView @JvmOverloads constructor(
// Only show blurhash if an image is going to be loaded from the network
if (url != null && blurHash != null) withContext(Dispatchers.IO) {
val blurHashBitmap = BlurHashDecoder.decode(
val blurHashBitmap = BlurHash.decode(
blurHash,
if (aspectRatio > 1) round(blurHashResolution * aspectRatio).toInt() else blurHashResolution,
if (aspectRatio >= 1) blurHashResolution else round(blurHashResolution / aspectRatio).toInt(),
@@ -92,9 +88,9 @@ class AsyncImageView @JvmOverloads constructor(
target(this@AsyncImageView)
data(url)
placeholder(placeholderOrBlurHash?.asImage())
placeholder(placeholderOrBlurHash)
if (circleCrop) transformations(CircleCropTransformation())
error(placeholder?.asImage())
error(placeholder)
}.build())
}
}

View File

@@ -11,9 +11,7 @@ import org.jellyfin.androidtv.auth.repository.UserRepository
import org.jellyfin.androidtv.databinding.ClockUserBugBinding
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.preference.constant.ClockBehavior
import org.jellyfin.androidtv.ui.navigation.Destinations
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
import org.jellyfin.androidtv.util.ImageHelper
import org.jellyfin.androidtv.util.ImageUtils
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@@ -26,8 +24,6 @@ class ClockUserView @JvmOverloads constructor(
private val binding: ClockUserBugBinding = ClockUserBugBinding.inflate(LayoutInflater.from(context), this, true)
private val userPreferences by inject<UserPreferences>()
private val userRepository by inject<UserRepository>()
private val navigationRepository by inject<NavigationRepository>()
private val imageHelper by inject<ImageHelper>()
var isVideoPlayer = false
set(value) {
@@ -35,23 +31,17 @@ class ClockUserView @JvmOverloads constructor(
updateClockVisibility()
}
val homeButton get() = binding.home
init {
updateClockVisibility()
val currentUser = userRepository.currentUser.value
binding.clockUserImage.load(
url = currentUser?.let(imageHelper::getPrimaryImageUrl),
url = currentUser?.let(ImageUtils::getPrimaryImageUrl),
placeholder = ContextCompat.getDrawable(context, R.drawable.ic_user)
)
binding.clockUserImage.isVisible = currentUser != null
binding.home.setOnClickListener {
navigationRepository.reset(Destinations.home, clearHistory = true)
}
}
private fun updateClockVisibility() {
@@ -63,7 +53,5 @@ class ClockUserView @JvmOverloads constructor(
ClockBehavior.IN_VIDEO -> isVideoPlayer
ClockBehavior.IN_MENUS -> !isVideoPlayer
}
binding.home.isVisible = !isVideoPlayer
}
}

View File

@@ -8,15 +8,14 @@ import android.widget.FrameLayout;
import android.widget.TextView;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.util.DateTimeExtensionsKt;
import org.jellyfin.androidtv.util.TimeUtils;
import java.time.LocalDateTime;
import java.util.Date;
public class FriendlyDateButton extends FrameLayout {
private LocalDateTime dateVal;
private long dateVal;
public FriendlyDateButton(Context context, LocalDateTime thisDate, OnClickListener listener) {
public FriendlyDateButton(Context context, long thisDate, OnClickListener listener) {
super(context);
LayoutInflater inflater = LayoutInflater.from(context);
@@ -26,13 +25,14 @@ public class FriendlyDateButton extends FrameLayout {
setOnClickListener(listener);
dateVal = thisDate;
Date date = new Date(thisDate);
((TextView)v.findViewById(R.id.friendlyName)).setText(TimeUtils.getFriendlyDate(context, thisDate, true));
((TextView)v.findViewById(R.id.date)).setText(DateTimeExtensionsKt.getDateFormatter(context).format(thisDate));
((TextView)v.findViewById(R.id.friendlyName)).setText(TimeUtils.getFriendlyDate(context, date, true));
((TextView)v.findViewById(R.id.date)).setText(android.text.format.DateFormat.getDateFormat(context).format(date));
}
public LocalDateTime getDate() { return dateVal; }
public long getDate() { return dateVal; }
@Override
protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) {

View File

@@ -14,24 +14,25 @@ import androidx.core.content.ContextCompat;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.ui.livetv.LiveTvGuide;
import org.jellyfin.androidtv.ui.livetv.LiveTvGuideFragment;
import org.jellyfin.androidtv.util.ImageHelper;
import org.jellyfin.androidtv.util.ImageUtils;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.sdk.model.api.BaseItemDto;
import org.jellyfin.apiclient.interaction.ApiClient;
import org.jellyfin.apiclient.model.livetv.ChannelInfoDto;
import org.koin.java.KoinJavaComponent;
public class GuideChannelHeader extends RelativeLayout {
private AsyncImageView mChannelImage;
private ImageView mFavImage;
private BaseItemDto mChannel;
private ChannelInfoDto mChannel;
private Context mContext;
private LiveTvGuide mTvGuide;
public GuideChannelHeader(Context context, LiveTvGuide tvGuide, BaseItemDto channel) {
public GuideChannelHeader(Context context, LiveTvGuide tvGuide, ChannelInfoDto channel) {
super(context);
initComponent(context, tvGuide, channel);
}
private void initComponent(Context context, LiveTvGuide tvGuide, BaseItemDto channel) {
private void initComponent(Context context, LiveTvGuide tvGuide, ChannelInfoDto channel) {
mContext = context;
mChannel = channel;
mTvGuide = tvGuide;
@@ -49,14 +50,13 @@ public class GuideChannelHeader extends RelativeLayout {
mChannelImage = findViewById(R.id.channelImage);
mFavImage = findViewById(R.id.favImage);
if (mChannel.getUserData() != null && mChannel.getUserData().isFavorite())
if (mChannel.getUserData() != null && mChannel.getUserData().getIsFavorite())
mFavImage.setVisibility(View.VISIBLE);
}
public void loadImage() {
ImageHelper imageHelper = KoinJavaComponent.<ImageHelper>get(ImageHelper.class);
mChannelImage.load(
imageHelper.getPrimaryImageUrl(mChannel,null, ImageHelper.MAX_PRIMARY_IMAGE_HEIGHT),
ImageUtils.getPrimaryImageUrl(mChannel, KoinJavaComponent.<ApiClient>get(ApiClient.class)),
null,
null,
0.0,
@@ -64,11 +64,10 @@ public class GuideChannelHeader extends RelativeLayout {
);
}
public BaseItemDto getChannel() { return mChannel; }
public void setChannel(BaseItemDto channel) { mChannel = channel; }
public ChannelInfoDto getChannel() { return mChannel; }
public void refreshFavorite() {
if (mChannel.getUserData() != null && mChannel.getUserData().isFavorite())
if (mChannel.getUserData() != null && mChannel.getUserData().getIsFavorite())
mFavImage.setVisibility(View.VISIBLE);
else
mFavImage.setVisibility(View.GONE);
@@ -86,4 +85,5 @@ public class GuideChannelHeader extends RelativeLayout {
setBackground(ContextCompat.getDrawable(mContext, R.drawable.light_border));
}
}
}

View File

@@ -46,12 +46,19 @@ public class ItemListView extends FrameLayout {
mItemIds.clear();
}
public void addItems(List<BaseItemDto> items) {
int i = 0;
for (BaseItemDto item : items) {
addItem(item, i++);
}
}
public void addItem(BaseItemDto item, int ndx) {
mList.addView(new ItemRowView(mContext, item, ndx, mRowSelectedListener, mRowClickedListener));
mItemIds.add(item.getId());
}
public ItemRowView updatePlaying(UUID id) {
public ItemRowView updatePlaying(String id) {
//look through our song rows and update the playing indicator
ItemRowView ret = null;
for (int i = 0; i < mList.getChildCount(); i++) {

View File

@@ -12,7 +12,7 @@ fun ItemListView.refresh() {
val api by KoinJavaComponent.inject<ApiClient>(ApiClient::class.java)
findViewTreeLifecycleOwner()?.lifecycleScope?.launch {
val response by api.itemsApi.getItems(
val response by api.itemsApi.getItemsByUserId(
ids = mItemIds,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,

View File

@@ -16,9 +16,7 @@ import org.jellyfin.androidtv.databinding.ItemRowBinding;
import org.jellyfin.androidtv.util.TimeUtils;
import org.jellyfin.androidtv.util.sdk.BaseItemExtensionsKt;
import org.jellyfin.sdk.model.api.BaseItemDto;
import org.jellyfin.sdk.model.api.MediaType;
import java.util.UUID;
import org.jellyfin.sdk.model.constant.MediaType;
public class ItemRowView extends FrameLayout {
Context mContext;
@@ -120,7 +118,7 @@ public class ItemRowView extends FrameLayout {
public void updateWatched() {
if (mBaseItem == null) return;
if (MediaType.VIDEO.equals(mBaseItem.getMediaType()) && mBaseItem.getUserData() != null && mBaseItem.getUserData().getPlayed()) {
if (MediaType.Video.equals(mBaseItem.getMediaType()) && mBaseItem.getUserData() != null && mBaseItem.getUserData().getPlayed()) {
mWatchedMark.setText("");
} else {
mWatchedMark.setText("");
@@ -151,7 +149,7 @@ public class ItemRowView extends FrameLayout {
return playing;
}
public boolean setPlaying(UUID id) {
public boolean setPlaying(String id) {
return setPlaying(getItem().getId().equals(id));
}

View File

@@ -1,5 +1,8 @@
package org.jellyfin.androidtv.ui;
import static org.koin.java.KoinJavaComponent.inject;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
@@ -15,28 +18,36 @@ import android.widget.TextView;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.ViewTreeLifecycleOwner;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.auth.repository.UserRepository;
import org.jellyfin.androidtv.data.model.DataRefreshService;
import org.jellyfin.androidtv.ui.livetv.LiveTvGuide;
import org.jellyfin.androidtv.ui.livetv.TvManager;
import org.jellyfin.androidtv.util.InfoLayoutHelper;
import org.jellyfin.androidtv.util.TimeUtils;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.androidtv.util.apiclient.EmptyLifecycleAwareResponse;
import org.jellyfin.sdk.model.api.BaseItemDto;
import org.jellyfin.androidtv.util.sdk.compat.ModelCompat;
import org.jellyfin.apiclient.interaction.ApiClient;
import org.jellyfin.apiclient.interaction.Response;
import org.jellyfin.apiclient.model.dto.BaseItemDto;
import org.jellyfin.apiclient.model.dto.UserItemDataDto;
import org.jellyfin.apiclient.model.livetv.ChannelInfoDto;
import org.jellyfin.apiclient.model.livetv.SeriesTimerInfoDto;
import org.koin.java.KoinJavaComponent;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Date;
import kotlin.Lazy;
import timber.log.Timber;
public class LiveProgramDetailPopup {
private PopupWindow mPopup;
private BaseItemDto mProgram;
private ProgramGridCell mSelectedProgramView;
final Context mContext;
final Lifecycle lifecycle;
private Activity mActivity;
private final Lifecycle lifecycle;
private LiveTvGuide mTvGuide;
private TextView mDTitle;
private TextView mDSummary;
@@ -54,15 +65,17 @@ public class LiveProgramDetailPopup {
private int mPosLeft;
private int mPosTop;
public LiveProgramDetailPopup(Context context, LifecycleOwner lifecycleOwner, LiveTvGuide tvGuide, int width, EmptyLifecycleAwareResponse tuneAction) {
mContext = context;
lifecycle = lifecycleOwner.getLifecycle();
private Lazy<ApiClient> apiClient = inject(ApiClient.class);
public LiveProgramDetailPopup(Activity activity, Lifecycle lifecycle, LiveTvGuide tvGuide, int width, EmptyLifecycleAwareResponse tuneAction) {
mActivity = activity;
this.lifecycle = lifecycle;
mTvGuide = tvGuide;
mTuneAction = tuneAction;
View layout = LayoutInflater.from(context).inflate(R.layout.program_detail_popup, null);
int popupHeight = Utils.convertDpToPixel(context, 400);
LayoutInflater inflater = (LayoutInflater) mActivity.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
View layout = inflater.inflate(R.layout.program_detail_popup, null);
int popupHeight = Utils.convertDpToPixel(activity, 400);
mPopup = new PopupWindow(layout, width, popupHeight);
ViewTreeLifecycleOwner.set(mPopup.getContentView(), lifecycleOwner);
mPopup.setFocusable(true);
mPopup.setOutsideTouchable(true);
mPopup.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); // necessary for popup to dismiss
@@ -83,127 +96,216 @@ public class LiveProgramDetailPopup {
public void setContent(final BaseItemDto program, final ProgramGridCell selectedGridView) {
mProgram = program;
mSelectedProgramView = selectedGridView;
mDTitle.setText(mProgram.getName());
mDTitle.setText(program.getName());
mDButtonRow.removeAllViews();
if (program.getId() == null) {
//empty item, just offer tune button
mFirstButton = createTuneButton();
createFavoriteButton();
mDInfoRow.removeAllViews();
mDTimeline.removeAllViews();
mDSummary.setText("");
return;
}
mDSummary.setText(mProgram.getOverview());
mDSummary.setText(program.getOverview());
if (mDSummary.getLineCount() < 2) {
mDSummary.setGravity(Gravity.CENTER);
} else {
mDSummary.setGravity(Gravity.START);
mDSummary.setGravity(Gravity.LEFT);
}
// build timeline info
TvManager.setTimelineRow(mContext, mDTimeline, mProgram);
TvManager.setTimelineRow(mActivity, mDTimeline, program);
//info row
// TODO: Requires LifecycleOwner for compose
// InfoLayoutHelper.addInfoRow(mContext, mProgram, mDInfoRow, false);
InfoLayoutHelper.addInfoRow(mActivity, ModelCompat.asSdk(program), mDInfoRow, false, false);
//buttons
mFirstButton = null;
if (mProgram.getEndDate().isAfter(LocalDateTime.now())) {
if (mProgram.getStartDate().isBefore(LocalDateTime.now())) {
Date now = new Date();
Date local = TimeUtils.convertToLocalDate(program.getStartDate());
if (TimeUtils.convertToLocalDate(program.getEndDate()).getTime() > now.getTime()) {
if (local.getTime() <= now.getTime()) {
// program in progress - tune first button
mFirstButton = createTuneButton();
}
if (Utils.canManageRecordings(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue())) {
if (mProgram.getTimerId() != null) {
if (program.getTimerId() != null) {
// cancel button
Button cancel = new Button(mContext);
cancel.setText(mContext.getResources().getString(R.string.lbl_cancel_recording));
Button cancel = new Button(mActivity);
cancel.setText(mActivity.getResources().getString(R.string.lbl_cancel_recording));
cancel.setTextColor(Color.WHITE);
cancel.setBackgroundResource(R.drawable.jellyfin_button);
cancel.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
LiveProgramDetailPopupHelperKt.cancelTimer(LiveProgramDetailPopup.this, mProgram.getTimerId(), () -> {
selectedGridView.setRecTimer(null);
mProgram = LiveProgramDetailPopupHelperKt.copyWithTimerId(mProgram, null);
dismiss();
Utils.showToast(mContext, R.string.msg_recording_cancelled);
return null;
apiClient.getValue().CancelLiveTvTimerAsync(program.getTimerId(), new EmptyLifecycleAwareResponse(lifecycle) {
@Override
public void onResponse() {
if (!getActive()) return;
selectedGridView.setRecTimer(null);
program.setTimerId(null);
dismiss();
Utils.showToast(mActivity, R.string.msg_recording_cancelled);
}
@Override
public void onError(Exception ex) {
if (!getActive()) return;
Utils.showToast(mActivity, R.string.msg_unable_to_cancel);
}
});
}
});
mDButtonRow.addView(cancel);
if (mFirstButton == null) mFirstButton = cancel;
// recording info
mDRecordInfo.setText(mProgram.getStartDate().isBefore(LocalDateTime.now()) ? mContext.getResources().getString(R.string.msg_recording_now) : mContext.getResources().getString(R.string.msg_will_record));
mDRecordInfo.setText(local.getTime() <= now.getTime() ? mActivity.getResources().getString(R.string.msg_recording_now) : mActivity.getResources().getString(R.string.msg_will_record));
} else {
// record button
Button rec = new Button(mContext);
rec.setText(mContext.getResources().getString(R.string.lbl_record));
Button rec = new Button(mActivity);
rec.setText(mActivity.getResources().getString(R.string.lbl_record));
rec.setTextColor(Color.WHITE);
rec.setBackgroundResource(R.drawable.jellyfin_button);
rec.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
LiveProgramDetailPopupHelperKt.recordProgram(LiveProgramDetailPopup.this, mProgram.getId(), program -> {
mProgram = program;
mSelectedProgramView.setRecSeriesTimer(program.getSeriesTimerId());
mSelectedProgramView.setRecTimer(program.getTimerId());
if (mSeriesSettingsButton != null)
mSeriesSettingsButton.setVisibility(View.VISIBLE);
Utils.showToast(mContext, R.string.msg_set_to_record);
dismiss();
return null;
//Create one-off recording with defaults
apiClient.getValue().GetDefaultLiveTvTimerInfo(mProgram.getId(), new Response<SeriesTimerInfoDto>() {
@Override
public void onResponse(SeriesTimerInfoDto response) {
apiClient.getValue().CreateLiveTvTimerAsync(response, new EmptyLifecycleAwareResponse(lifecycle) {
@Override
public void onResponse() {
if (!getActive()) return;
// we have to re-retrieve the program to get the timer id
apiClient.getValue().GetLiveTvProgramAsync(mProgram.getId(), KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString(), new Response<BaseItemDto>() {
@Override
public void onResponse(BaseItemDto response) {
mProgram = response;
mSelectedProgramView.setRecSeriesTimer(response.getSeriesTimerId());
mSelectedProgramView.setRecTimer(response.getTimerId());
Utils.showToast(mActivity, R.string.msg_set_to_record);
dismiss();
}
});
}
@Override
public void onError(Exception ex) {
if (!getActive()) return;
Timber.e(ex, "Error creating recording");
Utils.showToast(mActivity, R.string.msg_unable_to_create_recording);
}
});
}
@Override
public void onError(Exception exception) {
Timber.e(exception, "Error creating recording");
Utils.showToast(mActivity, R.string.msg_unable_to_create_recording);
}
});
}
});
mDButtonRow.addView(rec);
if (mFirstButton == null) mFirstButton = rec;
mDRecordInfo.setText(mProgram.getSeriesTimerId() == null ? "" : mContext.getString(R.string.lbl_episode_not_record));
mDRecordInfo.setText(program.getSeriesTimerId() == null ? "" : mActivity.getString(R.string.lbl_episode_not_record));
}
if (Utils.isTrue(mProgram.isSeries())) {
if (mProgram.getSeriesTimerId() != null) {
if (Utils.isTrue(program.getIsSeries())) {
if (program.getSeriesTimerId() != null) {
// cancel series button
Button cancel = new Button(mContext);
cancel.setText(mContext.getResources().getString(R.string.lbl_cancel_series));
Button cancel = new Button(mActivity);
cancel.setText(mActivity.getResources().getString(R.string.lbl_cancel_series));
cancel.setTextColor(Color.WHITE);
cancel.setBackgroundResource(R.drawable.jellyfin_button);
cancel.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
new AlertDialog.Builder(mContext)
.setTitle(mContext.getResources().getString(R.string.lbl_cancel_series))
.setMessage(mContext.getResources().getString(R.string.msg_cancel_entire_series))
new AlertDialog.Builder(mActivity)
.setTitle(mActivity.getResources().getString(R.string.lbl_cancel_series))
.setMessage(mActivity.getResources().getString(R.string.msg_cancel_entire_series))
.setNegativeButton(R.string.lbl_no, null)
.setPositiveButton(R.string.lbl_yes, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
LiveProgramDetailPopupHelperKt.cancelSeriesTimer(LiveProgramDetailPopup.this, mProgram.getSeriesTimerId(), () -> {
selectedGridView.setRecSeriesTimer(null);
mProgram = LiveProgramDetailPopupHelperKt.copyWithSeriesTimerId(mProgram, null);
mSeriesSettingsButton.setVisibility(View.GONE);
dismiss();
Utils.showToast(mContext, R.string.msg_recording_cancelled);
return null;
apiClient.getValue().CancelLiveTvSeriesTimerAsync(program.getSeriesTimerId(), new EmptyLifecycleAwareResponse(lifecycle) {
@Override
public void onResponse() {
if (!getActive()) return;
selectedGridView.setRecSeriesTimer(null);
program.setSeriesTimerId(null);
mSeriesSettingsButton.setVisibility(View.GONE);
dismiss();
Utils.showToast(mActivity, R.string.msg_recording_cancelled);
}
@Override
public void onError(Exception ex) {
if (!getActive()) return;
Utils.showToast(mActivity, R.string.msg_unable_to_cancel);
}
});
}
}).show();
}
});
mDButtonRow.addView(cancel);
} else {
}else {
// record series button
Button rec = new Button(mContext);
rec.setText(mContext.getResources().getString(R.string.lbl_record_series));
Button rec = new Button(mActivity);
rec.setText(mActivity.getResources().getString(R.string.lbl_record_series));
rec.setTextColor(Color.WHITE);
rec.setBackgroundResource(R.drawable.jellyfin_button);
rec.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
LiveProgramDetailPopupHelperKt.recordSeries(LiveProgramDetailPopup.this, mProgram.getId(), program -> {
mProgram = program;
mSelectedProgramView.setRecSeriesTimer(program.getSeriesTimerId());
mSelectedProgramView.setRecTimer(program.getTimerId());
if (mSeriesSettingsButton != null)
mSeriesSettingsButton.setVisibility(View.VISIBLE);
Utils.showToast(mContext, R.string.msg_set_to_record);
dismiss();
return null;
//Create series recording with defaults
apiClient.getValue().GetDefaultLiveTvTimerInfo(mProgram.getId(), new Response<SeriesTimerInfoDto>() {
@Override
public void onResponse(SeriesTimerInfoDto response) {
apiClient.getValue().CreateLiveTvSeriesTimerAsync(response, new EmptyLifecycleAwareResponse(lifecycle) {
@Override
public void onResponse() {
if (!getActive()) return;
// we have to re-retrieve the program to get the timer id
apiClient.getValue().GetLiveTvProgramAsync(mProgram.getId(), KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString(), new Response<BaseItemDto>() {
@Override
public void onResponse(BaseItemDto response) {
mProgram = response;
mSelectedProgramView.setRecSeriesTimer(response.getSeriesTimerId());
mSelectedProgramView.setRecTimer(response.getTimerId());
if (mSeriesSettingsButton != null) mSeriesSettingsButton.setVisibility(View.VISIBLE);
Utils.showToast(mActivity, R.string.msg_set_to_record);
dismiss();
}
});
}
@Override
public void onError(Exception ex) {
if (!getActive()) return;
Timber.e(ex, "Error creating recording");
Utils.showToast(mActivity, R.string.msg_unable_to_create_recording);
}
});
}
@Override
public void onError(Exception exception) {
Timber.e(exception, "Error creating recording");
Utils.showToast(mActivity, R.string.msg_unable_to_create_recording);
}
});
}
});
@@ -211,8 +313,8 @@ public class LiveProgramDetailPopup {
}
// manage series button
mSeriesSettingsButton = new Button(mContext);
mSeriesSettingsButton.setText(mContext.getResources().getString(R.string.lbl_series_settings));
mSeriesSettingsButton = new Button(mActivity);
mSeriesSettingsButton.setText(mActivity.getResources().getString(R.string.lbl_series_settings));
mSeriesSettingsButton.setTextColor(Color.WHITE);
mSeriesSettingsButton.setBackgroundResource(R.drawable.jellyfin_button);
mSeriesSettingsButton.setVisibility(mProgram.getSeriesTimerId() != null ? View.VISIBLE : View.GONE);
@@ -228,18 +330,27 @@ public class LiveProgramDetailPopup {
}
if (mProgram.getStartDate().isAfter(LocalDateTime.now())) {
if (local.getTime() > now.getTime()) {
// add tune to button for programs that haven't started yet
createTuneButton();
}
createFavoriteButton();
} else {
// program has already ended
mDRecordInfo.setText(mContext.getResources().getString(R.string.lbl_program_ended));
mDRecordInfo.setText(mActivity.getResources().getString(R.string.lbl_program_ended));
mFirstButton = createTuneButton();
}
// if (program.getIsMovie()) {
// mDSimilarRow.setVisibility(View.VISIBLE);
// mPopup.setHeight(MOVIE_HEIGHT);
// } else {
mDSimilarRow.setVisibility(View.GONE);
// mPopup.setHeight(NORMAL_HEIGHT);
//
// }
}
public Button createTuneButton() {
@@ -256,19 +367,22 @@ public class LiveProgramDetailPopup {
}
public android.widget.ImageButton createFavoriteButton() {
BaseItemDto channel = TvManager.getChannel(TvManager.getAllChannelsIndex(mProgram.getChannelId()));
boolean isFav = channel.getUserData() != null && channel.getUserData().isFavorite();
ChannelInfoDto channel = TvManager.getChannel(TvManager.getAllChannelsIndex(mProgram.getChannelId()));
boolean isFav = channel.getUserData() != null && channel.getUserData().getIsFavorite();
android.widget.ImageButton fave = addImgButton(mDButtonRow, isFav ? R.drawable.ic_heart_red : R.drawable.ic_heart);
fave.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
LiveProgramDetailPopupHelperKt.toggleFavorite(LiveProgramDetailPopup.this, channel, channel -> {
fave.setImageDrawable(ContextCompat.getDrawable(mContext, channel.getUserData().isFavorite() ? R.drawable.ic_heart_red : R.drawable.ic_heart));
mTvGuide.refreshFavorite(channel.getId());
DataRefreshService dataRefreshService = KoinJavaComponent.<DataRefreshService>get(DataRefreshService.class);
dataRefreshService.setLastFavoriteUpdate(Instant.now());
return null;
apiClient.getValue().UpdateFavoriteStatusAsync(channel.getId(), KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString(), !channel.getUserData().getIsFavorite(), new Response<UserItemDataDto>() {
@Override
public void onResponse(UserItemDataDto response) {
channel.setUserData(response);
fave.setImageDrawable(ContextCompat.getDrawable(mActivity, response.getIsFavorite() ? R.drawable.ic_heart_red : R.drawable.ic_heart));
mTvGuide.refreshFavorite(channel.getId());
DataRefreshService dataRefreshService = KoinJavaComponent.<DataRefreshService>get(DataRefreshService.class);
dataRefreshService.setLastFavoriteUpdate(System.currentTimeMillis());
}
});
}
});
@@ -277,16 +391,16 @@ public class LiveProgramDetailPopup {
}
private android.widget.ImageButton addImgButton(LinearLayout layout, int imgResource) {
android.widget.ImageButton btn = new android.widget.ImageButton(mContext);
btn.setImageDrawable(ContextCompat.getDrawable(mContext, imgResource));
android.widget.ImageButton btn = new android.widget.ImageButton(mActivity);
btn.setImageDrawable(ContextCompat.getDrawable(mActivity, imgResource));
btn.setBackgroundResource(R.drawable.jellyfin_button);
layout.addView(btn);
return btn;
}
private Button addButton(LinearLayout layout, int stringResource) {
Button btn = new Button(mContext);
btn.setText(mContext.getResources().getString(stringResource));
Button btn = new Button(mActivity);
btn.setText(mActivity.getResources().getString(stringResource));
btn.setTextColor(Color.WHITE);
btn.setBackgroundResource(R.drawable.jellyfin_button);
layout.addView(btn);
@@ -310,13 +424,15 @@ public class LiveProgramDetailPopup {
private RecordPopup mRecordPopup;
public void showRecordingOptions(final boolean recordSeries) {
if (mRecordPopup == null)
mRecordPopup = new RecordPopup(mContext, lifecycle, mAnchor, mPosLeft, mPosTop, mPopup.getWidth());
LiveProgramDetailPopupHelperKt.getSeriesTimer(this, mProgram.getSeriesTimerId(), seriesTimer -> {
mRecordPopup.setContent(mContext, mProgram, seriesTimer, mSelectedProgramView, recordSeries);
mRecordPopup.show();
return null;
if (mRecordPopup == null) mRecordPopup = new RecordPopup(mActivity, lifecycle, mAnchor, mPosLeft, mPosTop, mPopup.getWidth());
apiClient.getValue().GetLiveTvSeriesTimerAsync(mProgram.getSeriesTimerId(), new Response<SeriesTimerInfoDto>() {
@Override
public void onResponse(SeriesTimerInfoDto response) {
mRecordPopup.setContent(mActivity, ModelCompat.asSdk(mProgram), response, mSelectedProgramView, recordSeries);
mRecordPopup.show();
}
});
}
}

View File

@@ -1,151 +0,0 @@
package org.jellyfin.androidtv.ui
import androidx.lifecycle.coroutineScope
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.data.repository.ItemMutationRepository
import org.jellyfin.androidtv.util.getActivity
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.SeriesTimerInfoDto
import org.jellyfin.sdk.model.api.TimerInfoDto
import org.koin.android.ext.android.inject
import java.util.UUID
fun BaseItemDto.copyWithTimerId(
timerId: String?,
) = copy(
timerId = timerId,
)
fun BaseItemDto.copyWithSeriesTimerId(
seriesTimerId: String?,
) = copy(
seriesTimerId = seriesTimerId,
)
fun LiveProgramDetailPopup.cancelTimer(
timerId: String,
callback: () -> Unit,
) {
val api by mContext.getActivity()!!.inject<ApiClient>()
lifecycle.coroutineScope.launch {
runCatching {
api.liveTvApi.cancelTimer(timerId)
}.onSuccess {
callback()
}
}
}
fun LiveProgramDetailPopup.cancelSeriesTimer(
seriesTimerId: String,
callback: () -> Unit,
) {
val api by mContext.getActivity()!!.inject<ApiClient>()
lifecycle.coroutineScope.launch {
runCatching {
api.liveTvApi.cancelSeriesTimer(seriesTimerId)
}.onSuccess {
callback()
}
}
}
fun SeriesTimerInfoDto.asTimerInfoDto() = TimerInfoDto(
id = id,
type = type,
serverId = serverId,
externalId = externalId,
channelId = channelId,
externalChannelId = externalChannelId,
channelName = channelName,
channelPrimaryImageTag = channelPrimaryImageTag,
programId = programId,
externalProgramId = externalProgramId,
name = name,
overview = overview,
startDate = startDate,
endDate = endDate,
serviceName = serviceName,
priority = priority,
prePaddingSeconds = prePaddingSeconds,
postPaddingSeconds = postPaddingSeconds,
isPrePaddingRequired = isPrePaddingRequired,
parentBackdropItemId = parentBackdropItemId,
parentBackdropImageTags = parentBackdropImageTags,
isPostPaddingRequired = isPostPaddingRequired,
keepUntil = keepUntil,
)
fun LiveProgramDetailPopup.recordProgram(
programId: UUID,
callback: (program: BaseItemDto) -> Unit,
) {
val api by mContext.getActivity()!!.inject<ApiClient>()
lifecycle.coroutineScope.launch {
runCatching {
val seriesTimer by api.liveTvApi.getDefaultTimer(programId.toString())
val timer = seriesTimer.asTimerInfoDto()
api.liveTvApi.createTimer(timer)
api.liveTvApi.getProgram(programId.toString()).content
}.onSuccess { program ->
callback(program)
}
}
}
fun LiveProgramDetailPopup.recordSeries(
programId: UUID,
callback: (program: BaseItemDto) -> Unit,
) {
val api by mContext.getActivity()!!.inject<ApiClient>()
lifecycle.coroutineScope.launch {
runCatching {
val timer by api.liveTvApi.getDefaultTimer(programId.toString())
api.liveTvApi.createSeriesTimer(timer)
api.liveTvApi.getProgram(programId.toString()).content
}.onSuccess { program ->
callback(program)
}
}
}
fun LiveProgramDetailPopup.getSeriesTimer(
seriesTimerId: String,
callback: (seriesTimer: SeriesTimerInfoDto) -> Unit,
) {
val api by mContext.getActivity()!!.inject<ApiClient>()
lifecycle.coroutineScope.launch {
runCatching {
api.liveTvApi.getSeriesTimer(seriesTimerId).content
}.onSuccess { seriesTimer ->
callback(seriesTimer)
}
}
}
fun LiveProgramDetailPopup.toggleFavorite(
item: BaseItemDto,
callback: (item: BaseItemDto) -> Unit,
) {
val itemMutationRepository by mContext.getActivity()!!.inject<ItemMutationRepository>()
lifecycle.coroutineScope.launch {
runCatching {
val userData = itemMutationRepository.setFavorite(
item = item.id,
favorite = !(item.userData?.isFavorite ?: false)
)
item.copy(userData = userData)
}.onSuccess { item ->
callback(item)
}
}
}

View File

@@ -2,156 +2,102 @@ package org.jellyfin.androidtv.ui
import android.content.Context
import android.util.AttributeSet
import android.widget.ImageView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import android.view.LayoutInflater
import android.widget.FrameLayout
import androidx.core.content.ContextCompat
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import androidx.core.view.isVisible
import androidx.core.view.setPadding
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.composable.AsyncImage
import org.jellyfin.androidtv.ui.composable.rememberPlayerProgress
import org.jellyfin.androidtv.ui.composable.rememberQueueEntry
import org.jellyfin.androidtv.databinding.ViewNowPlayingBinding
import org.jellyfin.androidtv.ui.navigation.Destinations
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
import org.jellyfin.androidtv.util.ImageHelper
import org.jellyfin.playback.core.PlaybackManager
import org.jellyfin.playback.jellyfin.queue.baseItem
import org.jellyfin.playback.jellyfin.queue.baseItemFlow
import org.jellyfin.sdk.model.api.ImageType
import org.koin.compose.koinInject
@Composable
fun NowPlayingComposable() {
val playbackManager = koinInject<PlaybackManager>()
val navigationRepository = koinInject<NavigationRepository>()
val imageHelper = koinInject<ImageHelper>()
val entry by rememberQueueEntry(playbackManager)
val item = entry?.run { baseItemFlow.collectAsState(baseItem) }?.value
val progress = rememberPlayerProgress(playbackManager)
AnimatedVisibility(
visible = item != null,
enter = fadeIn(),
exit = fadeOut(),
) {
Surface(
onClick = { navigationRepository.navigate(Destinations.nowPlaying) },
colors = ClickableSurfaceDefaults.colors(
containerColor = colorResource(id = R.color.button_default_normal_background),
focusedContainerColor = colorResource(id = R.color.button_default_highlight_background),
contentColor = colorResource(id = R.color.button_default_normal_text),
focusedContentColor = colorResource(id = R.color.button_default_highlight_text),
),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
shape = ClickableSurfaceDefaults.shape(
shape = RoundedCornerShape(4.dp),
),
modifier = Modifier
.widthIn(0.dp, 250.dp)
) {
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.height(1.dp)
.drawWithContent {
// Background
drawRect(Color.White, alpha = 0.4f)
// Foreground
drawRect(Color.White, size = size.copy(width = progress * size.width))
}
)
ProvideTextStyle(
value = TextStyle.Default.copy(
fontSize = 12.sp,
)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(5.dp)
) {
val primaryImageTag = item?.imageTags?.get(ImageType.PRIMARY)
val (imageItemId, imageTag) = when {
primaryImageTag != null -> item.id to primaryImageTag
(item?.albumId != null && item.albumPrimaryImageTag != null) -> item.albumId to item.albumPrimaryImageTag
else -> null to null
}
val imageUrl = when {
imageItemId != null && imageTag != null -> imageHelper.getImageUrl(
itemId = imageItemId,
imageType = ImageType.PRIMARY,
imageTag = imageTag
)
else -> null
}
val imageBlurHash = imageTag?.let { tag ->
item?.imageBlurHashes?.get(ImageType.PRIMARY)?.get(tag)
}
AsyncImage(
url = imageUrl,
blurHash = imageBlurHash,
placeholder = ContextCompat.getDrawable(LocalContext.current, R.drawable.ic_album),
aspectRatio = item?.primaryImageAspectRatio ?: 1.0,
modifier = Modifier
.size(35.dp)
.clip(RoundedCornerShape(4.dp)),
scaleType = ImageView.ScaleType.CENTER_CROP,
)
Column(
verticalArrangement = Arrangement.SpaceAround,
) {
// Name
Text(text = item?.name.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis)
val artists = item?.artists ?: item?.albumArtists ?: item?.albumArtist?.let(::listOf)
Text(text = artists?.joinToString(", ").orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
}
import org.jellyfin.androidtv.ui.playback.AudioEventListener
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.androidtv.ui.playback.PlaybackController
import org.jellyfin.androidtv.util.ImageUtils
import org.jellyfin.androidtv.util.TimeUtils
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class NowPlayingView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
) : AbstractComposeView(context, attrs, defStyle) {
@Composable
override fun Content() = NowPlayingComposable()
defStyleAttr: Int = 0,
defStyleRes: Int = R.style.Button_Default,
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes), KoinComponent {
val binding = ViewNowPlayingBinding.inflate(LayoutInflater.from(context), this, true)
private val mediaManager by inject<MediaManager>()
private val navigationRepository by inject<NavigationRepository>()
private var currentDuration: String = ""
init {
setPadding(0)
if (!isInEditMode) setOnClickListener {
navigationRepository.navigate(Destinations.nowPlaying)
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (!isInEditMode) {
// hook our events
mediaManager.addAudioEventListener(audioEventListener)
if (mediaManager.hasAudioQueueItems()) {
isVisible = true
setInfo(mediaManager.currentAudioItem!!)
setStatus(mediaManager.currentAudioPosition)
} else isVisible = false
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
if (!isInEditMode) mediaManager.removeAudioEventListener(audioEventListener)
}
private fun setInfo(item: org.jellyfin.sdk.model.api.BaseItemDto) {
val placeholder = ContextCompat.getDrawable(context, R.drawable.ic_album)
val blurHash = item.imageBlurHashes?.get(org.jellyfin.sdk.model.api.ImageType.PRIMARY)?.get(item.imageTags?.get(org.jellyfin.sdk.model.api.ImageType.PRIMARY))
binding.npIcon.load(ImageUtils.getPrimaryImageUrl(item), blurHash, placeholder, item.primaryImageAspectRatio ?: 1.0)
currentDuration = TimeUtils.formatMillis(if (item.runTimeTicks != null) item.runTimeTicks!! / 10_000 else 0)
binding.npDesc.text = if (item.albumArtist != null) item.albumArtist else item.name
}
private fun setStatus(pos: Long) {
binding.npStatus.text = resources.getString(R.string.lbl_status, TimeUtils.formatMillis(pos), currentDuration)
}
fun showDescription(show: Boolean) {
binding.npDesc.isVisible = show
}
private var audioEventListener: AudioEventListener = object : AudioEventListener {
override fun onPlaybackStateChange(newState: PlaybackController.PlaybackState, currentItem: org.jellyfin.sdk.model.api.BaseItemDto?) {
when {
currentItem == null -> Unit
newState == PlaybackController.PlaybackState.PLAYING -> setInfo(currentItem)
newState == PlaybackController.PlaybackState.IDLE && isShown -> setStatus(mediaManager.currentAudioPosition)
}
}
override fun onProgress(pos: Long) {
if (isShown) setStatus(pos)
}
override fun onQueueStatusChanged(hasQueue: Boolean) {
isVisible = hasQueue
if (hasQueue) {
// may have just added one so update display
setInfo(mediaManager.currentAudioItem!!)
setStatus(mediaManager.currentAudioPosition)
}
}
}
}

View File

@@ -17,12 +17,14 @@ import android.widget.TextView;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.preference.LiveTvPreferences;
import org.jellyfin.androidtv.ui.livetv.LiveTvGuide;
import org.jellyfin.androidtv.util.DateTimeExtensionsKt;
import org.jellyfin.androidtv.util.InfoLayoutHelper;
import org.jellyfin.androidtv.util.TimeUtils;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.androidtv.util.sdk.BaseItemExtensionsKt;
import org.jellyfin.sdk.model.api.BaseItemDto;
import org.jellyfin.androidtv.util.sdk.compat.ModelCompat;
import org.jellyfin.apiclient.model.dto.BaseItemDto;
import java.time.LocalDateTime;
import java.util.Date;
public class ProgramGridCell extends RelativeLayout implements RecordingIndicatorView {
@@ -58,37 +60,37 @@ public class ProgramGridCell extends RelativeLayout implements RecordingIndicato
setCellBackground();
if (program.getStartDate() != null && program.getEndDate() != null) {
LocalDateTime localStart = program.getStartDate();
if (localStart.plusMinutes(1).isBefore(activity.getCurrentLocalStartDate())) {
Date localStart = TimeUtils.convertToLocalDate(program.getStartDate());
if (localStart.getTime() + 60000 < activity.getCurrentLocalStartDate()) {
mProgramName.setText("<< "+mProgramName.getText());
TextView time = new TextView(context);
time.setTypeface(Typeface.create("sans-serif-light", Typeface.NORMAL));
time.setTextSize(12);
time.setText(DateTimeExtensionsKt.getTimeFormatter(getContext()).format(program.getStartDate()));
time.setText(android.text.format.DateFormat.getTimeFormat(getContext()).format(TimeUtils.convertToLocalDate(program.getStartDate())));
mInfoRow.addView(time);
}
}
LiveTvPreferences liveTvPreferences = get(LiveTvPreferences.class);
if (liveTvPreferences.get(LiveTvPreferences.Companion.getShowNewIndicator()) && BaseItemExtensionsKt.isNew(program) && (!liveTvPreferences.get(LiveTvPreferences.Companion.getShowPremiereIndicator()) || !Utils.isTrue(program.isPremiere()))) {
addBlockText(context.getString(R.string.lbl_new), 10, Color.GRAY, R.drawable.dark_green_gradient);
if (liveTvPreferences.get(LiveTvPreferences.Companion.getShowNewIndicator()) && BaseItemExtensionsKt.isNew(ModelCompat.asSdk(program)) && (!liveTvPreferences.get(LiveTvPreferences.Companion.getShowPremiereIndicator()) || !Utils.isTrue(program.getIsPremiere()))) {
InfoLayoutHelper.addBlockText(context, mInfoRow, context.getString(R.string.lbl_new), 10, Color.GRAY, R.drawable.dark_green_gradient);
}
if (liveTvPreferences.get(LiveTvPreferences.Companion.getShowPremiereIndicator()) && Utils.isTrue(program.isPremiere())) {
addBlockText(context.getString(R.string.lbl_premiere), 10, Color.GRAY, R.drawable.dark_green_gradient);
if (liveTvPreferences.get(LiveTvPreferences.Companion.getShowPremiereIndicator()) && Utils.isTrue(program.getIsPremiere())) {
InfoLayoutHelper.addBlockText(context, mInfoRow, context.getString(R.string.lbl_premiere), 10, Color.GRAY, R.drawable.dark_green_gradient);
}
if (liveTvPreferences.get(LiveTvPreferences.Companion.getShowRepeatIndicator()) && Utils.isTrue(program.isRepeat())) {
addBlockText(context.getString(R.string.lbl_repeat), 10, Color.GRAY, androidx.leanback.R.color.lb_default_brand_color);
if (liveTvPreferences.get(LiveTvPreferences.Companion.getShowRepeatIndicator()) && Utils.isTrue(program.getIsRepeat())) {
InfoLayoutHelper.addBlockText(context, mInfoRow, context.getString(R.string.lbl_repeat), 10, Color.GRAY, androidx.leanback.R.color.lb_default_brand_color);
}
if (program.getOfficialRating() != null && !program.getOfficialRating().equals("0")) {
addBlockText(program.getOfficialRating(), 10, Color.BLACK, R.drawable.block_text_bg);
InfoLayoutHelper.addBlockText(context, mInfoRow, program.getOfficialRating(), 10);
}
if (liveTvPreferences.get(LiveTvPreferences.Companion.getShowHDIndicator()) && Utils.isTrue(program.isHd())) {
addBlockText("HD", 10, Color.BLACK, R.drawable.block_text_bg);
if (liveTvPreferences.get(LiveTvPreferences.Companion.getShowHDIndicator()) && Utils.isTrue(program.getIsHD())) {
InfoLayoutHelper.addBlockText(context, mInfoRow, "HD", 10);
}
if (program.getSeriesTimerId() != null) {
@@ -109,29 +111,17 @@ public class ProgramGridCell extends RelativeLayout implements RecordingIndicato
}
private void addBlockText(String text, int size, int textColor, int backgroundRes) {
TextView view = new TextView(getContext());
view.setTextSize(size);
view.setTextColor(textColor);
view.setText(" " + text + " ");
view.setBackgroundResource(backgroundRes);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT));
params.setMargins(0, Utils.convertDpToPixel(getContext(), -2), 0, 0);
view.setLayoutParams(params);
mInfoRow.addView(view);
}
public void setCellBackground() {
LiveTvPreferences liveTvPreferences = get(LiveTvPreferences.class);
if (liveTvPreferences.get(LiveTvPreferences.Companion.getColorCodeGuide())) {
if (Utils.isTrue(mProgram.isMovie())) {
if (Utils.isTrue(mProgram.getIsMovie())) {
mBackgroundColor = getResources().getColor(R.color.guide_movie_bg);
} else if (Utils.isTrue(mProgram.isNews())) {
} else if (Utils.isTrue(mProgram.getIsNews())) {
mBackgroundColor = getResources().getColor(R.color.guide_news_bg);
} else if (Utils.isTrue(mProgram.isSports())) {
} else if (Utils.isTrue(mProgram.getIsSports())) {
mBackgroundColor = getResources().getColor(R.color.guide_sports_bg);
} else if (Utils.isTrue(mProgram.isKids())) {
} else if (Utils.isTrue(mProgram.getIsKids())) {
mBackgroundColor = getResources().getColor(R.color.guide_kids_bg);
}
@@ -160,13 +150,11 @@ public class ProgramGridCell extends RelativeLayout implements RecordingIndicato
public boolean isFirst() { return isFirst; }
public void setRecTimer(String id) {
if (mProgram == null) return;
mProgram = LiveProgramDetailPopupHelperKt.copyWithTimerId(mProgram, id);
mProgram.setTimerId(id);
mRecIndicator.setImageResource(id != null ? (mProgram.getSeriesTimerId() != null ? R.drawable.ic_record_series_red : R.drawable.ic_record_red) : mProgram.getSeriesTimerId() != null ? R.drawable.ic_record_series : R.drawable.blank10x10);
}
public void setRecSeriesTimer(String id) {
if (mProgram == null) return;
mProgram = LiveProgramDetailPopupHelperKt.copyWithSeriesTimerId(mProgram, id);
mProgram.setSeriesTimerId(id);
mRecIndicator.setImageResource(id != null ? R.drawable.ic_record_series_red : R.drawable.blank10x10);
}
}

View File

@@ -2,6 +2,7 @@ package org.jellyfin.androidtv.ui;
import static org.koin.java.KoinJavaComponent.inject;
import android.app.Activity;
import android.content.Context;
import android.graphics.Color;
import android.graphics.Typeface;
@@ -22,28 +23,29 @@ import android.widget.TextView;
import androidx.lifecycle.Lifecycle;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.auth.repository.UserRepository;
import org.jellyfin.androidtv.constant.CustomMessage;
import org.jellyfin.androidtv.data.repository.CustomMessageRepository;
import org.jellyfin.androidtv.util.ContextExtensionsKt;
import org.jellyfin.androidtv.util.DateTimeExtensionsKt;
import org.jellyfin.androidtv.util.TimeUtils;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.androidtv.util.apiclient.EmptyLifecycleAwareResponse;
import org.jellyfin.apiclient.interaction.ApiClient;
import org.jellyfin.apiclient.interaction.Response;
import org.jellyfin.apiclient.model.livetv.SeriesTimerInfoDto;
import org.jellyfin.apiclient.model.livetv.TimerInfoDto;
import org.jellyfin.sdk.model.api.BaseItemDto;
import org.jellyfin.sdk.model.api.SeriesTimerInfoDto;
import org.jellyfin.sdk.model.api.TimerInfoDto;
import org.koin.java.KoinJavaComponent;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.UUID;
import java.util.Date;
import kotlin.Lazy;
public class RecordPopup {
PopupWindow mPopup;
UUID mProgramId;
String mProgramId;
SeriesTimerInfoDto mCurrentOptions;
RecordingIndicatorView mSelectedView;
View mAnchorView;
@@ -51,8 +53,8 @@ public class RecordPopup {
int mPosTop;
boolean mRecordSeries;
Context mContext;
final Lifecycle lifecycle;
Activity mActivity;
private final Lifecycle lifecycle;
TextView mDTitle;
LinearLayout mDTimeline;
View mSeriesOptions;
@@ -68,41 +70,43 @@ public class RecordPopup {
ArrayList<String> mPaddingDisplayOptions;
ArrayList<Integer> mPaddingValues = new ArrayList<>(Arrays.asList(0,60,300,900,1800,3600,5400,7200,10800));
private Lazy<ApiClient> apiClient = inject(ApiClient.class);
private Lazy<CustomMessageRepository> customMessageRepository = inject(CustomMessageRepository.class);
public RecordPopup(Context context, Lifecycle lifecycle, View anchorView, int left, int top, int width) {
mContext = context;
public RecordPopup(Activity activity, Lifecycle lifecycle, View anchorView, int left, int top, int width) {
mActivity = activity;
this.lifecycle = lifecycle;
mAnchorView = anchorView;
mPosLeft = left;
mPosTop = top;
mPaddingDisplayOptions = new ArrayList<>(Arrays.asList(
context.getString(R.string.lbl_on_schedule),
ContextExtensionsKt.getQuantityString(context, R.plurals.minutes, 1),
ContextExtensionsKt.getQuantityString(context, R.plurals.minutes, 5),
ContextExtensionsKt.getQuantityString(context, R.plurals.minutes, 15),
ContextExtensionsKt.getQuantityString(context, R.plurals.minutes, 30),
ContextExtensionsKt.getQuantityString(context, R.plurals.minutes, 60),
ContextExtensionsKt.getQuantityString(context, R.plurals.minutes, 90),
ContextExtensionsKt.getQuantityString(context, R.plurals.hours, 2),
ContextExtensionsKt.getQuantityString(context, R.plurals.hours, 3)
mActivity.getString(R.string.lbl_on_schedule),
ContextExtensionsKt.getQuantityString(activity, R.plurals.minutes, 1),
ContextExtensionsKt.getQuantityString(activity, R.plurals.minutes, 5),
ContextExtensionsKt.getQuantityString(activity, R.plurals.minutes, 15),
ContextExtensionsKt.getQuantityString(activity, R.plurals.minutes, 30),
ContextExtensionsKt.getQuantityString(activity, R.plurals.minutes, 60),
ContextExtensionsKt.getQuantityString(activity, R.plurals.minutes, 90),
ContextExtensionsKt.getQuantityString(activity, R.plurals.hours, 2),
ContextExtensionsKt.getQuantityString(activity, R.plurals.hours, 3)
));
View layout = LayoutInflater.from(context).inflate(R.layout.new_program_record_popup, null);
int popupHeight = Utils.convertDpToPixel(context, 330);
LayoutInflater inflater = (LayoutInflater) mActivity.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
View layout = inflater.inflate(R.layout.new_program_record_popup, null);
int popupHeight = Utils.convertDpToPixel(activity, 330);
mPopup = new PopupWindow(layout, width, popupHeight);
mPopup.setFocusable(true);
mPopup.setOutsideTouchable(true);
mPopup.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); // necessary for popup to dismiss
mDTitle = layout.findViewById(R.id.title);
mDTitle = (TextView)layout.findViewById(R.id.title);
mPrePadding = layout.findViewById(R.id.prePadding);
mPrePadding.setAdapter(new ArrayAdapter<>(mContext, android.R.layout.simple_spinner_item, mPaddingDisplayOptions));
mPrePadding = (Spinner) layout.findViewById(R.id.prePadding);
mPrePadding.setAdapter(new ArrayAdapter<>(mActivity, android.R.layout.simple_spinner_item, mPaddingDisplayOptions));
mPrePadding.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
mCurrentOptions = RecordPopupHelperKt.copyWithPrePaddingSeconds(mCurrentOptions, mPaddingValues.get(position));
mCurrentOptions.setPrePaddingSeconds(mPaddingValues.get(position));
}
@Override
@@ -111,11 +115,11 @@ public class RecordPopup {
}
});
mPostPadding = (Spinner) layout.findViewById(R.id.postPadding);
mPostPadding.setAdapter(new ArrayAdapter<>(mContext, android.R.layout.simple_spinner_item, mPaddingDisplayOptions));
mPostPadding.setAdapter(new ArrayAdapter<>(mActivity, android.R.layout.simple_spinner_item, mPaddingDisplayOptions));
mPostPadding.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
mCurrentOptions = RecordPopupHelperKt.copyWithPostPaddingSeconds(mCurrentOptions, mPaddingValues.get(position));
mCurrentOptions.setPostPaddingSeconds(mPaddingValues.get(position));
}
@Override
@@ -124,39 +128,68 @@ public class RecordPopup {
}
});
mOnlyNew = layout.findViewById(R.id.onlyNew);
mAnyChannel = layout.findViewById(R.id.anyChannel);
mAnyTime = layout.findViewById(R.id.anyTime);
mOnlyNew = (CheckBox) layout.findViewById(R.id.onlyNew);
mAnyChannel = (CheckBox) layout.findViewById(R.id.anyChannel);
mAnyTime = (CheckBox) layout.findViewById(R.id.anyTime);
mSeriesOptions = layout.findViewById(R.id.seriesOptions);
mOkButton = layout.findViewById(R.id.okButton);
mOkButton = (Button) layout.findViewById(R.id.okButton);
mOkButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (mRecordSeries) {
mCurrentOptions = RecordPopupHelperKt.copyWithFilters(mCurrentOptions, mOnlyNew.isChecked(), mAnyChannel.isChecked(), mAnyTime.isChecked());
mCurrentOptions.setRecordNewOnly(mOnlyNew.isChecked());
mCurrentOptions.setRecordAnyChannel(mAnyChannel.isChecked());
mCurrentOptions.setRecordAnyTime(mAnyTime.isChecked());
apiClient.getValue().UpdateLiveTvSeriesTimerAsync(mCurrentOptions, new EmptyLifecycleAwareResponse(lifecycle) {
@Override
public void onResponse() {
mPopup.dismiss();
customMessageRepository.getValue().pushMessage(CustomMessage.ActionComplete.INSTANCE);
Utils.showToast(mActivity, R.string.msg_settings_updated);
}
@Override
public void onError(Exception ex) {
Utils.showToast(mActivity, R.string.msg_unable_to_create_recording);
}
RecordPopupHelperKt.updateSeriesTimer(RecordPopup.this, mCurrentOptions, () -> {
mPopup.dismiss();
customMessageRepository.getValue().pushMessage(CustomMessage.ActionComplete.INSTANCE);
Utils.showToast(mContext, R.string.msg_settings_updated);
return null;
});
} else {
TimerInfoDto updated = RecordPopupHelperKt.createProgramTimerInfo(mProgramId, mCurrentOptions);
RecordPopupHelperKt.updateTimer(RecordPopup.this, updated, () -> {
mPopup.dismiss();
customMessageRepository.getValue().pushMessage(CustomMessage.ActionComplete.INSTANCE);
// we have to re-retrieve the program to get the timer id
RecordPopupHelperKt.getLiveTvProgram(RecordPopup.this, mProgramId, program -> {
mSelectedView.setRecTimer(program.getTimerId());
mSelectedView.setRecSeriesTimer(program.getSeriesTimerId());
return null;
});
Utils.showToast(mContext, R.string.msg_set_to_record);
return null;
} else {
TimerInfoDto updated = new TimerInfoDto();
updated.setProgramId(mProgramId);
updated.setPrePaddingSeconds(mCurrentOptions.getPrePaddingSeconds());
updated.setPostPaddingSeconds(mCurrentOptions.getPostPaddingSeconds());
updated.setIsPrePaddingRequired(mCurrentOptions.getIsPrePaddingRequired());
updated.setIsPostPaddingRequired(mCurrentOptions.getIsPostPaddingRequired());
apiClient.getValue().UpdateLiveTvTimerAsync(updated, new EmptyLifecycleAwareResponse(lifecycle) {
@Override
public void onResponse() {
if (!getActive()) return;
mPopup.dismiss();
customMessageRepository.getValue().pushMessage(CustomMessage.ActionComplete.INSTANCE);
// we have to re-retrieve the program to get the timer id
apiClient.getValue().GetLiveTvProgramAsync(mProgramId, KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString(), new Response<org.jellyfin.apiclient.model.dto.BaseItemDto>() {
@Override
public void onResponse(org.jellyfin.apiclient.model.dto.BaseItemDto response) {
mSelectedView.setRecTimer(response.getTimerId());
mSelectedView.setRecSeriesTimer(response.getSeriesTimerId());
}
});
Utils.showToast(mActivity, R.string.msg_set_to_record);
}
@Override
public void onError(Exception ex) {
if (!getActive()) return;
Utils.showToast(mActivity, R.string.msg_unable_to_create_recording);
}
});
}
}
@@ -178,7 +211,7 @@ public class RecordPopup {
}
public void setContent(Context context, BaseItemDto program, SeriesTimerInfoDto current, RecordingIndicatorView selectedView, boolean recordSeries) {
mProgramId = program.getId();
mProgramId = program.getId().toString();
mCurrentOptions = current;
mRecordSeries = recordSeries;
mSelectedView = selectedView;
@@ -230,24 +263,17 @@ public class RecordPopup {
timelineRow.removeAllViews();
if (program.getStartDate() == null) return;
LocalDateTime local = program.getStartDate();
TextView on = new TextView(mContext);
on.setText(mContext.getString(R.string.lbl_on));
Date local = TimeUtils.getDate(program.getStartDate());
TextView on = new TextView(mActivity);
on.setText(mActivity.getString(R.string.lbl_on));
timelineRow.addView(on);
TextView channel = new TextView(mContext);
TextView channel = new TextView(mActivity);
channel.setText(program.getChannelName());
channel.setTypeface(null, Typeface.BOLD);
channel.setTextColor(mContext.getResources().getColor(android.R.color.holo_blue_light));
channel.setTextColor(mActivity.getResources().getColor(android.R.color.holo_blue_light));
timelineRow.addView(channel);
TextView datetime = new TextView(mContext);
datetime.setText(new StringBuilder()
.append(TimeUtils.getFriendlyDate(context, local))
.append(" @ ")
.append(DateTimeExtensionsKt.getTimeFormatter(mContext).format(local))
.append(" (")
.append(DateUtils.getRelativeTimeSpanString(local.toInstant(ZoneOffset.UTC).toEpochMilli(), Instant.now().toEpochMilli(), 0))
.append(")")
);
TextView datetime = new TextView(mActivity);
datetime.setText(TimeUtils.getFriendlyDate(context, local)+ " @ "+android.text.format.DateFormat.getTimeFormat(mActivity).format(local)+ " ("+ DateUtils.getRelativeTimeSpanString(local.getTime())+")");
timelineRow.addView(datetime);
}

View File

@@ -1,94 +0,0 @@
package org.jellyfin.androidtv.ui
import androidx.lifecycle.coroutineScope
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.util.getActivity
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.SeriesTimerInfoDto
import org.jellyfin.sdk.model.api.TimerInfoDto
import org.koin.android.ext.android.inject
import java.util.UUID
fun SeriesTimerInfoDto.copyWithPrePaddingSeconds(
prePaddingSeconds: Int,
) = copy(
prePaddingSeconds = prePaddingSeconds,
)
fun SeriesTimerInfoDto.copyWithPostPaddingSeconds(
postPaddingSeconds: Int,
) = copy(
postPaddingSeconds = postPaddingSeconds,
)
fun SeriesTimerInfoDto.copyWithFilters(
recordNewOnly: Boolean,
recordAnyChannel: Boolean,
recordAnyTime: Boolean,
) = copy(
recordNewOnly = recordNewOnly,
recordAnyChannel = recordAnyChannel,
recordAnyTime = recordAnyTime,
)
fun RecordPopup.updateSeriesTimer(
seriesTimer: SeriesTimerInfoDto,
callback: () -> Unit,
) {
val api by mContext.getActivity()!!.inject<ApiClient>()
lifecycle.coroutineScope.launch {
runCatching {
val id = seriesTimer.id
if (id == null) api.liveTvApi.createSeriesTimer(seriesTimer)
else api.liveTvApi.updateSeriesTimer(id, seriesTimer)
}.onSuccess {
callback()
}
}
}
fun RecordPopup.updateTimer(
timer: TimerInfoDto,
callback: () -> Unit,
) {
val api by mContext.getActivity()!!.inject<ApiClient>()
lifecycle.coroutineScope.launch {
runCatching {
val id = timer.id
if (id == null) api.liveTvApi.createTimer(timer)
else api.liveTvApi.updateTimer(id, timer)
}.onSuccess {
callback()
}
}
}
fun createProgramTimerInfo(
programId: UUID,
options: SeriesTimerInfoDto,
) = TimerInfoDto(
programId = programId.toString(),
prePaddingSeconds = options.prePaddingSeconds,
postPaddingSeconds = options.postPaddingSeconds,
isPrePaddingRequired = options.isPrePaddingRequired,
isPostPaddingRequired = options.isPostPaddingRequired,
)
fun RecordPopup.getLiveTvProgram(
id: UUID,
callback: (program: BaseItemDto) -> Unit,
) {
val api by mContext.getActivity()!!.inject<ApiClient>()
lifecycle.coroutineScope.launch {
runCatching {
api.liveTvApi.getProgram(id.toString()).content
}.onSuccess { program ->
callback(program)
}
}
}

View File

@@ -46,7 +46,7 @@ class ScreensaverViewModel(
// Cancel pending timer (if any)
timer?.cancel()
// Hide when interacted with allowed cancellation or when disabled
// Hide when interacted with allowed cancelation or when disabled
if (_visible.value && (canCancel || !inAppEnabled || activityPaused)) {
_visible.value = false
}

View File

@@ -0,0 +1,55 @@
package org.jellyfin.androidtv.ui
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.widget.PopupWindow
import androidx.core.view.isVisible
import org.jellyfin.androidtv.databinding.SettingsPopupBinding
import org.jellyfin.androidtv.util.dp
class SettingsPopup(
private val context: Context,
private val anchor: View,
audioChangedListener: ValueChangedListener<Long>?,
subtitleChangedListener: ValueChangedListener<Long>?
) {
private val binding = SettingsPopupBinding.inflate(LayoutInflater.from(context), null, false)
.apply {
subtitleDelay.increment = SUBTITLE_DELAY_INCREMENT
subtitleDelay.valueChangedListener = subtitleChangedListener
audioDelay.valueChangedListener = audioChangedListener
}
val popupWindow = PopupWindow(binding.root, WIDTH.dp(context), FULL_HEIGHT.dp(context))
.apply {
isFocusable = true
isOutsideTouchable = true
setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
}
fun show(
hasSubtitles: Boolean,
audioDelay: Long,
subtitleDelay: Long
) {
binding.audioDelay.value = audioDelay
binding.subtitleDelayTxt.isVisible = hasSubtitles
binding.subtitleDelay.isVisible = hasSubtitles
binding.subtitleDelay.value = subtitleDelay
popupWindow.height = (if (hasSubtitles) FULL_HEIGHT else SINGLE_HEIGHT).dp(context)
popupWindow.showAsDropDown(anchor, 0, 0, Gravity.END)
}
companion object {
private const val WIDTH = 240
private const val FULL_HEIGHT = 230
private const val SINGLE_HEIGHT = 130
private const val SUBTITLE_DELAY_INCREMENT = 500L
}
}

View File

@@ -2,6 +2,7 @@ package org.jellyfin.androidtv.ui.background
import android.graphics.drawable.ColorDrawable
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@@ -17,7 +18,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
@@ -25,11 +25,10 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.data.service.BackgroundService
import org.koin.compose.koinInject
import org.koin.compose.rememberKoinInject
@Composable
private fun AppThemeBackground() {
@@ -61,10 +60,10 @@ private fun AppThemeBackground() {
}
@Composable
@OptIn(ExperimentalAnimationApi::class)
fun AppBackground() {
val backgroundService = koinInject<BackgroundService>()
val backgroundService = rememberKoinInject<BackgroundService>()
val currentBackground by backgroundService.currentBackground.collectAsState()
val blurBackground by backgroundService.blurBackground.collectAsState()
val enabled by backgroundService.enabled.collectAsState()
if (enabled) {
@@ -83,9 +82,7 @@ fun AppBackground() {
alignment = Alignment.Center,
contentScale = ContentScale.Crop,
colorFilter = ColorFilter.tint(colorResource(R.color.background_filter), BlendMode.SrcAtop),
modifier = Modifier
.fillMaxSize()
.then(if (blurBackground) Modifier.blur(10.dp) else Modifier)
modifier = Modifier.fillMaxSize()
)
} else {
AppThemeBackground()

View File

@@ -1,6 +1,10 @@
package org.jellyfin.androidtv.ui.browsing
import android.os.Bundle
import android.view.Gravity
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import androidx.leanback.app.BrowseSupportFragment
import androidx.leanback.widget.HeaderItem
import androidx.leanback.widget.ListRow
@@ -16,12 +20,17 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import org.jellyfin.androidtv.constant.Extras
import org.jellyfin.androidtv.data.service.BackgroundService
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.preference.UserPreferences.Companion.clockBehavior
import org.jellyfin.androidtv.preference.constant.ClockBehavior
import org.jellyfin.androidtv.ui.ClockUserView
import org.jellyfin.androidtv.ui.itemhandling.BaseRowItem
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapter
import org.jellyfin.androidtv.ui.presentation.CardPresenter
import org.jellyfin.androidtv.ui.presentation.MutableObjectAdapter
import org.jellyfin.androidtv.ui.presentation.PositionableListRowPresenter
import org.jellyfin.androidtv.util.Utils.convertDpToPixel
import org.jellyfin.sdk.model.api.BaseItemDto
import org.koin.android.ext.android.inject
@@ -31,8 +40,8 @@ abstract class BrowseFolderFragment : BrowseSupportFragment(), RowLoader {
protected var rows = mutableListOf<BrowseRowDef>()
private val userPreferences by inject<UserPreferences>()
private val backgroundService by inject<BackgroundService>()
private val itemLauncher by inject<ItemLauncher>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -48,9 +57,10 @@ abstract class BrowseFolderFragment : BrowseSupportFragment(), RowLoader {
// Add event listeners
onItemViewClickedListener = OnItemViewClickedListener { _: Presenter.ViewHolder?, item: Any?, _: RowPresenter.ViewHolder?, row: Row ->
if (item is BaseRowItem) {
itemLauncher.launch(
ItemLauncher.launch(
item,
(row as ListRow).adapter as ItemRowAdapter,
item.index,
requireContext()
)
}
@@ -60,7 +70,7 @@ abstract class BrowseFolderFragment : BrowseSupportFragment(), RowLoader {
backgroundService.clearBackgrounds()
} else {
val adapter = (row as? ListRow)?.adapter
if (adapter is ItemRowAdapter) adapter.loadMoreItemsIfNeeded(adapter.indexOf(item))
if (adapter is ItemRowAdapter) adapter.loadMoreItemsIfNeeded(item.index.toLong())
backgroundService.setBackground(item.baseItem)
}
@@ -102,4 +112,31 @@ abstract class BrowseFolderFragment : BrowseSupportFragment(), RowLoader {
}
}
}
override fun onStart() {
super.onStart()
// Check if clock should be visible
val showClock = userPreferences[clockBehavior]
if (showClock !== ClockBehavior.ALWAYS && showClock !== ClockBehavior.IN_MENUS) return
val root = requireActivity().findViewById<ViewGroup>(androidx.leanback.R.id.browse_frame)
// Move the title to the left to make way for the clock
val titleView = root.findViewById<TextView>(androidx.leanback.R.id.title_text)
if (titleView != null) {
val layoutParams = titleView.layoutParams as FrameLayout.LayoutParams
layoutParams.rightMargin = convertDpToPixel(root.context, 120)
titleView.layoutParams = layoutParams
}
// Add the clock element
val layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
val clockView = ClockUserView(root.context, null)
layoutParams.gravity = Gravity.TOP or Gravity.END
layoutParams.rightMargin = convertDpToPixel(root.context, 40)
layoutParams.topMargin = convertDpToPixel(root.context, 20)
clockView.layoutParams = layoutParams
root.addView(clockView)
}
}

View File

@@ -29,6 +29,7 @@ import androidx.leanback.widget.VerticalGridPresenter;
import androidx.lifecycle.Lifecycle;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.auth.repository.UserRepository;
import org.jellyfin.androidtv.constant.ChangeTriggerType;
import org.jellyfin.androidtv.constant.CustomMessage;
import org.jellyfin.androidtv.constant.Extras;
@@ -37,7 +38,9 @@ import org.jellyfin.androidtv.constant.ImageType;
import org.jellyfin.androidtv.constant.PosterSize;
import org.jellyfin.androidtv.constant.QueryType;
import org.jellyfin.androidtv.data.model.FilterOptions;
import org.jellyfin.androidtv.data.querying.GetUserViewsRequest;
import org.jellyfin.androidtv.data.querying.AlbumArtistsQuery;
import org.jellyfin.androidtv.data.querying.StdItemQuery;
import org.jellyfin.androidtv.data.querying.ViewQuery;
import org.jellyfin.androidtv.data.repository.CustomMessageRepository;
import org.jellyfin.androidtv.data.repository.UserViewsRepository;
import org.jellyfin.androidtv.data.service.BackgroundService;
@@ -49,23 +52,24 @@ import org.jellyfin.androidtv.ui.AlphaPickerView;
import org.jellyfin.androidtv.ui.itemhandling.BaseRowItem;
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher;
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapter;
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapterHelperKt;
import org.jellyfin.androidtv.ui.navigation.Destinations;
import org.jellyfin.androidtv.ui.navigation.NavigationRepository;
import org.jellyfin.androidtv.ui.presentation.CardPresenter;
import org.jellyfin.androidtv.ui.presentation.HorizontalGridPresenter;
import org.jellyfin.androidtv.util.CoroutineUtils;
import org.jellyfin.androidtv.util.ImageHelper;
import org.jellyfin.androidtv.util.ImageUtils;
import org.jellyfin.androidtv.util.InfoLayoutHelper;
import org.jellyfin.androidtv.util.KeyProcessor;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.androidtv.util.apiclient.EmptyLifecycleAwareResponse;
import org.jellyfin.sdk.api.client.ApiClient;
import org.jellyfin.androidtv.util.apiclient.LifecycleAwareResponse;
import org.jellyfin.apiclient.model.querying.ArtistsQuery;
import org.jellyfin.apiclient.model.querying.ItemFields;
import org.jellyfin.sdk.model.api.BaseItemDto;
import org.jellyfin.sdk.model.api.BaseItemKind;
import org.jellyfin.sdk.model.api.CollectionType;
import org.jellyfin.sdk.model.api.ItemSortBy;
import org.jellyfin.sdk.model.api.SortOrder;
import org.jellyfin.sdk.model.constant.CollectionType;
import org.jellyfin.sdk.model.constant.ItemSortBy;
import java.text.MessageFormat;
import java.util.HashMap;
@@ -116,11 +120,9 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
private final Lazy<BackgroundService> backgroundService = inject(BackgroundService.class);
private final Lazy<PreferencesRepository> preferencesRepository = inject(PreferencesRepository.class);
private final Lazy<UserViewsRepository> userViewsRepository = inject(UserViewsRepository.class);
private final Lazy<UserRepository> userRepository = inject(UserRepository.class);
private final Lazy<CustomMessageRepository> customMessageRepository = inject(CustomMessageRepository.class);
private final Lazy<NavigationRepository> navigationRepository = inject(NavigationRepository.class);
private final Lazy<ItemLauncher> itemLauncher = inject(ItemLauncher.class);
private final Lazy<KeyProcessor> keyProcessor = inject(KeyProcessor.class);
private final Lazy<ApiClient> api = inject(ApiClient.class);
private int mCardsScreenEst = 0;
private int mCardsScreenStride = 0;
@@ -128,6 +130,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
private final int MIN_NUM_CARDS = 5; // minimum number of visible cards we allow, this results in more empty space
private final double CARD_SPACING_PCT = 1.0; // 100% expressed as relative to the padding_left/top, which depends on the mCardFocusScale and AspectRatio
private final double CARD_SPACING_HORIZONTAL_BANNER_PCT = 0.5; // 50% allow horizontal card overlapping for banners, otherwise spacing is too large
private final int MIN_GRIDSIZE_CHANGE_DELTA = 4; // minimum pixel size changes, to trigger a recreate of the grid via onGridSizeMeasurements
private final int VIEW_SELECT_UPDATE_DELAY = 250; // delay in ms until we update the top-row info for a selected item
private boolean mDirty = true; // CardHeight, RowDef or GridSize changed
@@ -142,6 +145,16 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
mGridHeight = Math.round(display.heightPixels / getResources().getDisplayMetrics().density - 130.6f);
mGridWidth = Math.round(display.widthPixels / getResources().getDisplayMetrics().density);
sortOptions = new HashMap<>();
{
sortOptions.put(0, new SortOption(getString(R.string.lbl_name), ItemSortBy.SortName, SortOrder.ASCENDING));
sortOptions.put(1, new SortOption(getString(R.string.lbl_date_added), ItemSortBy.DateCreated + "," + ItemSortBy.SortName, SortOrder.DESCENDING));
sortOptions.put(2, new SortOption(getString(R.string.lbl_premier_date), ItemSortBy.PremiereDate + "," + ItemSortBy.SortName, SortOrder.DESCENDING));
sortOptions.put(3, new SortOption(getString(R.string.lbl_rating), ItemSortBy.OfficialRating + "," + ItemSortBy.SortName, SortOrder.ASCENDING));
sortOptions.put(4, new SortOption(getString(R.string.lbl_community_rating), ItemSortBy.CommunityRating + "," + ItemSortBy.SortName, SortOrder.DESCENDING));
sortOptions.put(5, new SortOption(getString(R.string.lbl_critic_rating), ItemSortBy.CriticRating + "," + ItemSortBy.SortName, SortOrder.DESCENDING));
sortOptions.put(6, new SortOption(getString(R.string.lbl_last_played), ItemSortBy.DatePlayed + "," + ItemSortBy.SortName, SortOrder.DESCENDING));
}
mActivity = getActivity();
@@ -159,26 +172,6 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
else
setGridPresenter(new HorizontalGridPresenter());
sortOptions = new HashMap<>();
{
sortOptions.put(0, new SortOption(getString(R.string.lbl_name), ItemSortBy.SORT_NAME, SortOrder.ASCENDING));
sortOptions.put(1, new SortOption(getString(R.string.lbl_date_added), ItemSortBy.DATE_CREATED, SortOrder.DESCENDING));
sortOptions.put(2, new SortOption(getString(R.string.lbl_premier_date), ItemSortBy.PREMIERE_DATE, SortOrder.DESCENDING));
sortOptions.put(3, new SortOption(getString(R.string.lbl_rating), ItemSortBy.OFFICIAL_RATING, SortOrder.ASCENDING));
sortOptions.put(4, new SortOption(getString(R.string.lbl_community_rating), ItemSortBy.COMMUNITY_RATING, SortOrder.DESCENDING));
sortOptions.put(5, new SortOption(getString(R.string.lbl_critic_rating), ItemSortBy.CRITIC_RATING, SortOrder.DESCENDING));
if (mFolder.getCollectionType() == CollectionType.TVSHOWS) {
sortOptions.put(6, new SortOption(getString(R.string.lbl_last_played), ItemSortBy.SERIES_DATE_PLAYED, SortOrder.DESCENDING));
} else {
sortOptions.put(6, new SortOption(getString(R.string.lbl_last_played), ItemSortBy.DATE_PLAYED, SortOrder.DESCENDING));
}
if (mFolder.getCollectionType() != null && mFolder.getCollectionType() == CollectionType.MOVIES) {
sortOptions.put(7, new SortOption(getString(R.string.lbl_runtime), ItemSortBy.RUNTIME, SortOrder.ASCENDING));
}
}
setDefaultGridRowCols(mPosterSizeSetting, mImageType);
setAutoCardGridValues();
setupQueries();
@@ -188,7 +181,12 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
binding = HorizontalGridBrowseBinding.inflate(inflater, container, false);
// Hide the description because we don't have room for it
binding.npBug.showDescription(false);
return binding.getRoot();
}
@@ -204,7 +202,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
@Override
public boolean onKey(View v, int keyCode, KeyEvent event) {
if (event.getAction() != KeyEvent.ACTION_UP) return false;
return keyProcessor.getValue().handleKey(keyCode, mCurrentItem, mActivity);
return KeyProcessor.HandleKey(keyCode, mCurrentItem, mActivity);
}
@Override
@@ -274,7 +272,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
public void setItem(BaseRowItem item) {
if (item != null) {
binding.title.setText(item.getFullName(requireContext()));
InfoLayoutHelper.addInfoRow(requireContext(), item.getBaseItem(), binding.infoRow, true);
InfoLayoutHelper.addInfoRow(requireContext(), item, binding.infoRow, true, true);
} else {
binding.title.setText("");
binding.infoRow.removeAllViews();
@@ -283,10 +281,10 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
public class SortOption {
public String name;
public ItemSortBy value;
public String value;
public SortOrder order;
public SortOption(String name, ItemSortBy value, SortOrder order) {
public SortOption(String name, String value, SortOrder order) {
this.name = name;
this.value = value;
this.order = order;
@@ -295,12 +293,13 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
private Map<Integer, SortOption> sortOptions;
private SortOption getSortOption(ItemSortBy value) {
for (SortOption sortOption : sortOptions.values()) {
if (sortOption.value.equals(value)) return sortOption;
private SortOption getSortOption(String value) {
for (Integer key : sortOptions.keySet()) {
SortOption option = sortOptions.get(key);
if (Objects.requireNonNull(option).value.equals(value)) return option;
}
return new SortOption(getString(R.string.lbl_bracket_unknown), ItemSortBy.SORT_NAME, SortOrder.ASCENDING);
return new SortOption("Unknown", "", SortOrder.ASCENDING);
}
public void setStatusText(String folderName) {
@@ -360,15 +359,15 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
BaseItemKind fType = folder.getType();
if (fType == BaseItemKind.AUDIO || fType == BaseItemKind.GENRE || fType == BaseItemKind.MUSIC_ALBUM || fType == BaseItemKind.MUSIC_ARTIST || fType == BaseItemKind.MUSIC_GENRE) {
return cardHeight;
} else if (fType == BaseItemKind.COLLECTION_FOLDER && CollectionType.MUSIC.equals(folder.getCollectionType())) {
} else if (fType == BaseItemKind.COLLECTION_FOLDER && CollectionType.Music.equals(folder.getCollectionType())) {
return cardHeight;
} else {
return cardHeight * ImageHelper.ASPECT_RATIO_2_3;
return cardHeight * ImageUtils.ASPECT_RATIO_2_3;
}
case THUMB:
return cardHeight * ImageHelper.ASPECT_RATIO_16_9;
return cardHeight * ImageUtils.ASPECT_RATIO_16_9;
case BANNER:
return cardHeight * ImageHelper.ASPECT_RATIO_BANNER;
return cardHeight * CardPresenter.ASPECT_RATIO_BANNER;
default:
throw new IllegalStateException("Unexpected value: " + imageType);
}
@@ -381,15 +380,15 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
BaseItemKind fType = folder.getType();
if (fType == BaseItemKind.AUDIO || fType == BaseItemKind.GENRE || fType == BaseItemKind.MUSIC_ALBUM || fType == BaseItemKind.MUSIC_ARTIST || fType == BaseItemKind.MUSIC_GENRE) {
return cardWidth;
} else if (fType == BaseItemKind.COLLECTION_FOLDER && CollectionType.MUSIC.equals(folder.getCollectionType())) {
} else if (fType == BaseItemKind.COLLECTION_FOLDER && CollectionType.Music.equals(folder.getCollectionType())) {
return cardWidth;
} else {
return cardWidth / ImageHelper.ASPECT_RATIO_2_3;
return cardWidth / ImageUtils.ASPECT_RATIO_2_3;
}
case THUMB:
return cardWidth / ImageHelper.ASPECT_RATIO_16_9;
return cardWidth / ImageUtils.ASPECT_RATIO_16_9;
case BANNER:
return cardWidth / ImageHelper.ASPECT_RATIO_BANNER;
return cardWidth / CardPresenter.ASPECT_RATIO_BANNER;
default:
throw new IllegalArgumentException("Unexpected value: " + imageType);
}
@@ -471,11 +470,11 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
if (numRows > 0) {
double paddingPct = cardScaling / numRows;
double spacingPct = ((paddingPct / 2.0) * CARD_SPACING_PCT) * (numRows - 1);
double spaceingPct = ((paddingPct / 2.0) * CARD_SPACING_PCT) * (numRows - 1);
double wastedSpacePct = paddingPct + spacingPct;
double usableCardSpace = mGridHeight / (1.0 + wastedSpacePct); // decrease size
double cardHeight = usableCardSpace / numRows;
double wastedSpacePct = paddingPct + spaceingPct;
double useableCardSpace = mGridHeight / (1.0 + wastedSpacePct); // decrease size
double cardHeight = useableCardSpace / numRows;
// fix any rounding errors and make pixel perfect
cardHeightInt = (int) Math.round(cardHeight);
@@ -499,14 +498,14 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
mCardsScreenStride = numRows;
} else if (numCols > 0) {
double paddingPct = cardScaling / numCols;
double spacingPct = ((paddingPct / 2.0) * CARD_SPACING_PCT) * (numCols - 1);
double spaceingPct = ((paddingPct / 2.0) * CARD_SPACING_PCT) * (numCols - 1);
if (mImageType == ImageType.BANNER) {
spacingPct = ((paddingPct / 2.0) * CARD_SPACING_HORIZONTAL_BANNER_PCT) * (numCols - 1);
spaceingPct = ((paddingPct / 2.0) * CARD_SPACING_HORIZONTAL_BANNER_PCT) * (numCols - 1);
}
double wastedSpacePct = paddingPct + spacingPct;
double usableCardSpace = mGridWidth / (1.0 + wastedSpacePct); // decrease size
double cardWidth = usableCardSpace / numCols;
double wastedSpacePct = paddingPct + spaceingPct;
double useableCardSpace = mGridWidth / (1.0 + wastedSpacePct); // decrease size
double cardWidth = useableCardSpace / numCols;
// fix any rounding errors and make pixel perfect
cardHeightInt = (int) Math.round(getCardHeightBy(cardWidth, mImageType, mFolder));
@@ -543,22 +542,63 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
}
private void setupQueries() {
StdItemQuery query = new StdItemQuery(new ItemFields[]{
ItemFields.PrimaryImageAspectRatio,
ItemFields.ChildCount,
ItemFields.MediaSources,
ItemFields.MediaStreams,
ItemFields.DisplayPreferencesId
});
query.setParentId(mParentId.toString());
if (mFolder.getType() == BaseItemKind.USER_VIEW || mFolder.getType() == BaseItemKind.COLLECTION_FOLDER) {
CollectionType type = mFolder.getCollectionType() != null ? mFolder.getCollectionType() : CollectionType.UNKNOWN;
if (type == CollectionType.MUSIC) {
//Special queries needed for album artists
String includeType = getArguments().getString(Extras.IncludeType, null);
if ("AlbumArtist".equals(includeType)) {
setRowDef(new BrowseRowDef("", BrowsingUtils.createAlbumArtistsRequest(mParentId), CHUNK_SIZE_MINIMUM, new ChangeTriggerType[]{}));
return;
} else if ("Artist".equals(includeType)) {
setRowDef(new BrowseRowDef("", BrowsingUtils.createArtistsRequest(mParentId), CHUNK_SIZE_MINIMUM, new ChangeTriggerType[]{}));
return;
}
String type = mFolder.getCollectionType() != null ? mFolder.getCollectionType().toLowerCase() : "";
switch (type) {
case CollectionType.Movies:
query.setIncludeItemTypes(new String[]{"Movie"});
query.setRecursive(true);
break;
case CollectionType.TvShows:
query.setIncludeItemTypes(new String[]{"Series"});
query.setRecursive(true);
break;
case CollectionType.BoxSets:
query.setIncludeItemTypes(new String[]{"BoxSet"});
query.setParentId(null);
query.setRecursive(true);
break;
case CollectionType.Music:
//Special queries needed for album artists
String includeType = getArguments().getString(Extras.IncludeType);
if ("AlbumArtist".equals(includeType)) {
AlbumArtistsQuery albumArtists = new AlbumArtistsQuery();
albumArtists.setUserId(userRepository.getValue().getCurrentUser().getValue().getId().toString());
albumArtists.setFields(new ItemFields[]{
ItemFields.PrimaryImageAspectRatio,
ItemFields.ItemCounts,
ItemFields.ChildCount
});
albumArtists.setParentId(mParentId.toString());
setRowDef(new BrowseRowDef("", albumArtists, CHUNK_SIZE_MINIMUM, new ChangeTriggerType[]{}));
return;
} else if ("Artist".equals(includeType)) {
ArtistsQuery artists = new ArtistsQuery();
artists.setUserId(userRepository.getValue().getCurrentUser().getValue().getId().toString());
artists.setFields(new ItemFields[]{
ItemFields.PrimaryImageAspectRatio,
ItemFields.ItemCounts,
ItemFields.ChildCount
});
artists.setParentId(mParentId.toString());
setRowDef(new BrowseRowDef("", artists, CHUNK_SIZE_MINIMUM, new ChangeTriggerType[]{}));
return;
}
query.setIncludeItemTypes(new String[]{includeType != null ? includeType : "MusicAlbum"});
query.setRecursive(true);
break;
}
}
setRowDef(new BrowseRowDef("", BrowsingUtils.createBrowseGridItemsRequest(mFolder), CHUNK_SIZE_MINIMUM, false, true));
setRowDef(new BrowseRowDef("", query, CHUNK_SIZE_MINIMUM, false, true));
}
@Override
@@ -569,6 +609,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
ImageType imageType = libraryPreferences.get(LibraryPreferences.Companion.getImageType());
GridDirection gridDirection = libraryPreferences.get(LibraryPreferences.Companion.getGridDirection());
if (mImageType != imageType || mPosterSizeSetting != posterSizeSetting || mGridDirection != gridDirection || mDirty) {
determiningPosterSize = true;
@@ -619,14 +660,19 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
chunkSize = Math.min(mCardsScreenEst + mCardsScreenStride, 150); // cap at 150
Timber.d("buildAdapter adjusting chunkSize to <%s> screenEst <%s>", chunkSize, mCardsScreenEst);
}
chunkSize=100;
switch (mRowDef.getQueryType()) {
case NextUp:
mAdapter = new ItemRowAdapter(requireContext(), mRowDef.getNextUpQuery(), true, mCardPresenter, null);
break;
case Season:
mAdapter = new ItemRowAdapter(requireContext(), mRowDef.getSeasonQuery(), mCardPresenter, null);
break;
case Upcoming:
mAdapter = new ItemRowAdapter(requireContext(), mRowDef.getUpcomingQuery(), mCardPresenter, null);
break;
case Views:
mAdapter = new ItemRowAdapter(requireContext(), GetUserViewsRequest.INSTANCE, mCardPresenter, null);
mAdapter = new ItemRowAdapter(requireContext(), ViewQuery.INSTANCE, mCardPresenter, null);
break;
case SimilarSeries:
mAdapter = new ItemRowAdapter(requireContext(), mRowDef.getSimilarQuery(), QueryType.SimilarSeries, mCardPresenter, null);
@@ -634,6 +680,9 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
case SimilarMovies:
mAdapter = new ItemRowAdapter(requireContext(), mRowDef.getSimilarQuery(), QueryType.SimilarMovies, mCardPresenter, null);
break;
case Persons:
mAdapter = new ItemRowAdapter(requireContext(), mRowDef.getPersonsQuery(), chunkSize, mCardPresenter, null);
break;
case LiveTvChannel:
mAdapter = new ItemRowAdapter(requireContext(), mRowDef.getTvChannelQuery(), 40, mCardPresenter, null);
break;
@@ -669,7 +718,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
setItem(null);
updateCounter(mAdapter.getTotalItems() > 0 ? 1 : 0);
}
mLetterButton.setVisibility(ItemSortBy.SORT_NAME.equals(mAdapter.getSortBy()) ? View.VISIBLE : View.GONE);
mLetterButton.setVisibility(ItemSortBy.SortName.equals(mAdapter.getSortBy()) ? View.VISIBLE : View.GONE);
if (mAdapter.getItemsLoaded() == 0) {
mGridView.setFocusable(false);
mHandler.postDelayed(() -> {
@@ -705,13 +754,11 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
private ImageButton mLetterButton;
private void updateDisplayPrefs() {
CoroutineUtils.runOnLifecycle(getLifecycle(), (coroutineScope, continuation) -> {
libraryPreferences.set(LibraryPreferences.Companion.getFilterFavoritesOnly(), mAdapter.getFilters().isFavoriteOnly());
libraryPreferences.set(LibraryPreferences.Companion.getFilterUnwatchedOnly(), mAdapter.getFilters().isUnwatchedOnly());
libraryPreferences.set(LibraryPreferences.Companion.getSortBy(), mAdapter.getSortBy());
libraryPreferences.set(LibraryPreferences.Companion.getSortOrder(), getSortOption(mAdapter.getSortBy()).order);
return libraryPreferences.commit(continuation);
});
libraryPreferences.set(LibraryPreferences.Companion.getFilterFavoritesOnly(), mAdapter.getFilters().isFavoriteOnly());
libraryPreferences.set(LibraryPreferences.Companion.getFilterUnwatchedOnly(), mAdapter.getFilters().isUnwatchedOnly());
libraryPreferences.set(LibraryPreferences.Companion.getSortBy(), mAdapter.getSortBy());
libraryPreferences.set(LibraryPreferences.Companion.getSortOrder(), getSortOption(mAdapter.getSortBy()).order);
CoroutineUtils.runOnLifecycle(getLifecycle(), (coroutineScope, continuation) -> libraryPreferences.commit(continuation));
}
private void addTools() {
@@ -727,9 +774,12 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
public void onClick(View v) {
//Create sort menu
PopupMenu sortMenu = new PopupMenu(getActivity(), binding.toolBar, Gravity.END);
for (Map.Entry<Integer, SortOption> entry : sortOptions.entrySet()) {
MenuItem item = sortMenu.getMenu().add(0, entry.getKey(), entry.getKey(), entry.getValue().name);
item.setChecked(entry.getValue().value.equals(libraryPreferences.get(LibraryPreferences.Companion.getSortBy())));
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, Objects.requireNonNull(option).name);
if (option.value.equals(libraryPreferences.get(LibraryPreferences.Companion.getSortBy())))
item.setChecked(true);
}
sortMenu.getMenu().setGroupCheckable(0, true, true);
sortMenu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() {
@@ -878,20 +928,31 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
}
private void refreshCurrentItem() {
if (mCurrentItem == null) return;
Timber.d("Refresh item \"%s\"", mCurrentItem.getFullName(requireContext()));
ItemRowAdapterHelperKt.refreshItem(mAdapter, api.getValue(), this, mCurrentItem, () -> {
//Now - if filtered make sure we still pass
if (mAdapter.getFilters() == null) return null;
if ((mAdapter.getFilters().isFavoriteOnly() && !mCurrentItem.isFavorite()) || (mAdapter.getFilters().isUnwatchedOnly() && mCurrentItem.isPlayed())) {
// if we are about to remove the current item, throw focus to toolbar so framework doesn't crash
binding.toolBar.requestFocus();
mAdapter.remove(mCurrentItem);
mAdapter.setTotalItems(mAdapter.getTotalItems() - 1);
updateCounter(mAdapter.indexOf(mCurrentItem));
}
return null;
});
if (mCurrentItem != null && mCurrentItem.getBaseItemType() != BaseItemKind.PHOTO && mCurrentItem.getBaseItemType() != BaseItemKind.PHOTO_ALBUM
&& mCurrentItem.getBaseItemType() != BaseItemKind.MUSIC_ARTIST && mCurrentItem.getBaseItemType() != BaseItemKind.MUSIC_ALBUM) {
Timber.d("Refresh item \"%s\"", mCurrentItem.getFullName(requireContext()));
BaseRowItem item = mCurrentItem;
item.refresh(new LifecycleAwareResponse<BaseItemDto>(getLifecycle()) {
@Override
public void onResponse(BaseItemDto response) {
if (!getActive()) return;
if (response == null) mAdapter.removeAt(mAdapter.indexOf(item), 1);
else mAdapter.notifyItemRangeChanged(mAdapter.indexOf(item), 1);
//Now - if filtered make sure we still pass
if (response != null && mAdapter.getFilters() != null) {
if ((mAdapter.getFilters().isFavoriteOnly() && !item.isFavorite()) || (mAdapter.getFilters().isUnwatchedOnly() && item.isPlayed())) {
// if we are about to remove the current item, throw focus to toolbar so framework doesn't crash
binding.toolBar.requestFocus();
mAdapter.remove(item);
mAdapter.setTotalItems(mAdapter.getTotalItems() - 1);
updateCounter(item.getIndex());
}
}
}
});
}
}
private final class ItemViewClickedListener implements OnItemViewClickedListener {
@@ -900,7 +961,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
RowPresenter.ViewHolder rowViewHolder, Row row) {
if (!(item instanceof BaseRowItem)) return;
itemLauncher.getValue().launch((BaseRowItem) item, mAdapter, requireContext());
ItemLauncher.launch((BaseRowItem) item, mAdapter, ((BaseRowItem) item).getIndex(), requireContext());
}
}
@@ -931,7 +992,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
mHandler.postDelayed(mDelayedSetItem, VIEW_SELECT_UPDATE_DELAY);
if (!determiningPosterSize)
mAdapter.loadMoreItemsIfNeeded(mAdapter.indexOf(mCurrentItem));
mAdapter.loadMoreItemsIfNeeded(mCurrentItem.getIndex());
}
}
}

View File

@@ -13,18 +13,30 @@ 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.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.sdk.model.api.BaseItemDto;
import org.jellyfin.sdk.model.api.TimerInfoDto;
import org.jellyfin.androidtv.util.apiclient.LifecycleAwareResponse;
import org.jellyfin.apiclient.interaction.ApiClient;
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.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.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import timber.log.Timber;
public class BrowseRecordingsFragment extends EnhancedBrowseFragment {
@Override
public void onResume() {
@@ -42,56 +54,125 @@ public class BrowseRecordingsFragment extends EnhancedBrowseFragment {
protected void setupQueries(final RowLoader rowLoader) {
showViews = true;
//Latest Recordings
mRows.add(new BrowseRowDef(getString(R.string.lbl_recent_recordings), BrowsingUtils.createLiveTVRecordingsRequest(40), 40));
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.setEnableImages(true);
recordings.setLimit(40);
mRows.add(new BrowseRowDef(getString(R.string.lbl_recent_recordings), recordings, 40));
//Movies
BrowseRowDef moviesDef = new BrowseRowDef(getString(R.string.lbl_movies), BrowsingUtils.createLiveTVMovieRecordingsRequest(), 60);
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.setEnableImages(true);
movies.setIsMovie(true);
BrowseRowDef moviesDef = new BrowseRowDef(getString(R.string.lbl_movies), movies, 60);
//Shows
BrowseRowDef showsDef = new BrowseRowDef(getString(R.string.lbl_tv_series), BrowsingUtils.createLiveTVSeriesRecordingsRequest(), 60);
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.setEnableImages(true);
shows.setIsSeries(true);
BrowseRowDef showsDef = new BrowseRowDef(getString(R.string.lbl_tv_series), shows, 60);
mRows.add(showsDef);
mRows.add(moviesDef);
//Sports
mRows.add(new BrowseRowDef(getString(R.string.lbl_sports), BrowsingUtils.createLiveTVSportsRecordingsRequest(), 60));
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.setEnableImages(true);
sports.setIsSports(true);
mRows.add(new BrowseRowDef(getString(R.string.lbl_sports), sports, 60));
//Kids
mRows.add(new BrowseRowDef(getString(R.string.lbl_kids), BrowsingUtils.createLiveTVKidsRecordingsRequest(), 60));
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.setEnableImages(true);
kids.setIsKids(true);
mRows.add(new BrowseRowDef(getString(R.string.lbl_kids), kids, 60));
rowLoader.loadRows(mRows);
addNext24Timers();
}
private void addNext24Timers() {
BrowseViewFragmentHelperKt.getLiveTvTimers(this, timers -> {
List<BaseItemDto> nearTimers = new ArrayList<>();
LocalDateTime next24 = LocalDateTime.now().plusDays(1);
//Get scheduled items for next 24 hours
for (TimerInfoDto timer : timers.getItems()) {
if (timer.getStartDate().isBefore(next24)) {
nearTimers.add(BrowseViewFragmentHelperKt.getTimerProgramInfo(timer));
final TimerQuery scheduled = new TimerQuery();
final long ticks24 = 1000 * 60 * 60 * 24;
KoinJavaComponent.<ApiClient>get(ApiClient.class).GetLiveTvTimersAsync(scheduled, new LifecycleAwareResponse<TimerInfoDtoResult>(getLifecycle()) {
@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
for (TimerInfoDto timer : response.getItems()) {
if (TimeUtils.convertToLocalDate(timer.getStartDate()).getTime() <= next24) {
BaseItemDto programInfo = timer.getProgramInfo();
if (programInfo == null) {
programInfo = new BaseItemDto();
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());
programInfo.setBaseItemType(BaseItemType.Program);
programInfo.setTimerId(timer.getId());
programInfo.setSeriesTimerId(timer.getSeriesTimerId());
programInfo.setStartDate(timer.getStartDate());
programInfo.setEndDate(timer.getEndDate());
}
programInfo.setLocationType(LocationType.Virtual);
nearTimers.add(programInfo);
}
}
if (nearTimers.size() > 0) {
ItemRowAdapter scheduledAdapter = new ItemRowAdapter(requireContext(), 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);
}
}
if (!nearTimers.isEmpty()) {
ItemRowAdapter scheduledAdapter = new ItemRowAdapter(requireContext(), nearTimers, mCardPresenter, mRowsAdapter, true);
scheduledAdapter.Retrieve();
ListRow scheduleRow = new ListRow(new HeaderItem(getString(R.string.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);
@Override
public void onError(Exception exception) {
if (!getActive()) return;
Utils.showToast(getContext(), exception.getLocalizedMessage());
}
return null;
}, exception -> {
Utils.showToast(getContext(), exception.getLocalizedMessage());
return null;
});
}

View File

@@ -2,34 +2,41 @@ package org.jellyfin.androidtv.ui.browsing;
import org.jellyfin.androidtv.constant.ChangeTriggerType;
import org.jellyfin.androidtv.constant.QueryType;
import org.jellyfin.androidtv.data.querying.GetSeriesTimersRequest;
import org.jellyfin.androidtv.data.querying.GetSpecialsRequest;
import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest;
import org.jellyfin.sdk.model.api.request.GetArtistsRequest;
import org.jellyfin.sdk.model.api.request.GetItemsRequest;
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest;
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest;
import org.jellyfin.sdk.model.api.request.GetNextUpRequest;
import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest;
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest;
import org.jellyfin.androidtv.data.querying.AlbumArtistsQuery;
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.RecordingQuery;
import org.jellyfin.apiclient.model.livetv.SeriesTimerQuery;
import org.jellyfin.apiclient.model.querying.ArtistsQuery;
import org.jellyfin.apiclient.model.querying.ItemQuery;
import org.jellyfin.apiclient.model.querying.LatestItemsQuery;
import org.jellyfin.apiclient.model.querying.NextUpQuery;
import org.jellyfin.apiclient.model.querying.PersonsQuery;
import org.jellyfin.apiclient.model.querying.SeasonQuery;
import org.jellyfin.apiclient.model.querying.SimilarItemsQuery;
import org.jellyfin.apiclient.model.querying.UpcomingEpisodesQuery;
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest;
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest;
public class BrowseRowDef {
private String headerText;
private GetItemsRequest query;
private GetNextUpRequest nextUpQuery;
private GetSimilarItemsRequest similarQuery;
private GetLatestMediaRequest latestItemsQuery;
private GetLiveTvChannelsRequest tvChannelQuery;
private GetRecommendedProgramsRequest programQuery;
private GetRecordingsRequest recordingQuery;
private GetSeriesTimersRequest seriesTimerQuery;
private ItemQuery query;
private NextUpQuery nextUpQuery;
private UpcomingEpisodesQuery upcomingQuery;
private SimilarItemsQuery similarQuery;
private LatestItemsQuery latestItemsQuery;
private GetArtistsRequest artistsQuery;
private GetAlbumArtistsRequest albumArtistsQuery;
private PersonsQuery personsQuery;
private LiveTvChannelQuery tvChannelQuery;
private RecommendedProgramQuery programQuery;
private RecordingQuery recordingQuery;
private SeriesTimerQuery seriesTimerQuery;
private ArtistsQuery artistsQuery;
private AlbumArtistsQuery albumArtistsQuery;
private SeasonQuery seasonQuery;
private GetResumeItemsRequest resumeQuery;
private GetSpecialsRequest specialsQuery;
private QueryType queryType;
private int chunkSize = 0;
@@ -38,11 +45,14 @@ public class BrowseRowDef {
private ChangeTriggerType[] changeTriggers;
public BrowseRowDef(String header, GetItemsRequest query, int chunkSize) {
this(header, query, chunkSize, false, false);
public BrowseRowDef(String header, ItemQuery query, int chunkSize) {
this(header, query, chunkSize, false);
}
public BrowseRowDef(String header, ItemQuery query, int chunkSize, boolean preferParentThumb) {
this(header, query, chunkSize, preferParentThumb, false);
}
public BrowseRowDef(String header, GetItemsRequest query, int chunkSize, boolean preferParentThumb, boolean staticHeight) {
public BrowseRowDef(String header, ItemQuery query, int chunkSize, boolean preferParentThumb, boolean staticHeight) {
headerText = header;
this.query = query;
this.chunkSize = chunkSize;
@@ -51,15 +61,15 @@ public class BrowseRowDef {
this.queryType = QueryType.Items;
}
public BrowseRowDef(String header, GetItemsRequest query, int chunkSize, ChangeTriggerType[] changeTriggers) {
public BrowseRowDef(String header, ItemQuery query, int chunkSize, ChangeTriggerType[] changeTriggers) {
this(header, query, chunkSize, false, false, changeTriggers);
}
public BrowseRowDef(String header, GetItemsRequest query, int chunkSize, boolean preferParentThumb, boolean staticHeight, ChangeTriggerType[] changeTriggers) {
public BrowseRowDef(String header, ItemQuery query, int chunkSize, boolean preferParentThumb, boolean staticHeight, ChangeTriggerType[] changeTriggers) {
this(header,query,chunkSize,preferParentThumb,staticHeight,changeTriggers,QueryType.Items);
}
public BrowseRowDef(String header, GetItemsRequest query, int chunkSize, boolean preferParentThumb, boolean staticHeight, ChangeTriggerType[] changeTriggers, QueryType queryType) {
public BrowseRowDef(String header, ItemQuery query, int chunkSize, boolean preferParentThumb, boolean staticHeight, ChangeTriggerType[] changeTriggers, QueryType queryType) {
headerText = header;
this.query = query;
this.chunkSize = chunkSize;
@@ -69,7 +79,7 @@ public class BrowseRowDef {
this.changeTriggers = changeTriggers;
}
public BrowseRowDef(String header, GetArtistsRequest query, int chunkSize, ChangeTriggerType[] changeTriggers) {
public BrowseRowDef(String header, ArtistsQuery query, int chunkSize, ChangeTriggerType[] changeTriggers) {
headerText = header;
this.artistsQuery = query;
this.chunkSize = chunkSize;
@@ -77,7 +87,7 @@ public class BrowseRowDef {
this.changeTriggers = changeTriggers;
}
public BrowseRowDef(String header, GetAlbumArtistsRequest query, int chunkSize, ChangeTriggerType[] changeTriggers) {
public BrowseRowDef(String header, AlbumArtistsQuery query, int chunkSize, ChangeTriggerType[] changeTriggers) {
headerText = header;
this.albumArtistsQuery = query;
this.chunkSize = chunkSize;
@@ -85,14 +95,20 @@ public class BrowseRowDef {
this.changeTriggers = changeTriggers;
}
public BrowseRowDef(String header, GetSeriesTimersRequest query) {
public BrowseRowDef(String header, NextUpQuery query) {
headerText = header;
this.nextUpQuery = query;
this.queryType = QueryType.NextUp;
}
public BrowseRowDef(String header, SeriesTimerQuery query) {
headerText = header;
this.seriesTimerQuery = query;
this.staticHeight = true;
this.queryType = QueryType.SeriesTimer;
}
public BrowseRowDef(String header, GetNextUpRequest query, ChangeTriggerType[] changeTriggers) {
public BrowseRowDef(String header, NextUpQuery query, ChangeTriggerType[] changeTriggers) {
headerText = header;
this.nextUpQuery = query;
this.queryType = QueryType.NextUp;
@@ -100,7 +116,7 @@ public class BrowseRowDef {
this.changeTriggers = changeTriggers;
}
public BrowseRowDef(String header, GetLatestMediaRequest query, ChangeTriggerType[] changeTriggers) {
public BrowseRowDef(String header, LatestItemsQuery query, ChangeTriggerType[] changeTriggers) {
headerText = header;
this.latestItemsQuery = query;
this.queryType = QueryType.LatestItems;
@@ -108,35 +124,67 @@ public class BrowseRowDef {
this.changeTriggers = changeTriggers;
}
public BrowseRowDef(String header, GetLiveTvChannelsRequest query) {
public BrowseRowDef(String header, SimilarItemsQuery query) {
headerText = header;
this.similarQuery = query;
this.queryType = QueryType.SimilarSeries;
}
public BrowseRowDef(String header, LiveTvChannelQuery query) {
headerText = header;
this.tvChannelQuery = query;
this.queryType = QueryType.LiveTvChannel;
}
public BrowseRowDef(String header, GetRecommendedProgramsRequest query) {
public BrowseRowDef(String header, RecommendedProgramQuery query) {
headerText = header;
this.programQuery = query;
this.queryType = QueryType.LiveTvProgram;
this.changeTriggers = new ChangeTriggerType[] {ChangeTriggerType.GuideNeedsLoad};
}
public BrowseRowDef(String header, GetRecordingsRequest query) {
public BrowseRowDef(String header, RecordingQuery query) {
this(header, query, 0);
}
public BrowseRowDef(String header, GetRecordingsRequest query, int chunkSize) {
public BrowseRowDef(String header, RecordingQuery query, int chunkSize) {
headerText = header;
this.recordingQuery = query;
this.chunkSize = chunkSize;
this.queryType = QueryType.LiveTvRecording;
}
public BrowseRowDef(String header, GetSimilarItemsRequest query, QueryType type) {
public BrowseRowDef(String header, PersonsQuery query, int chunkSize) {
headerText = header;
this.personsQuery = query;
this.queryType = QueryType.Persons;
this.chunkSize = chunkSize;
}
public BrowseRowDef(String header, SimilarItemsQuery query, QueryType type) {
headerText = header;
this.similarQuery = query;
this.queryType = type;
}
public BrowseRowDef(String header, SeasonQuery query) {
headerText = header;
this.seasonQuery = query;
this.queryType = QueryType.Season;
}
public BrowseRowDef(String header, UpcomingEpisodesQuery query) {
headerText = header;
this.upcomingQuery = query;
this.queryType = QueryType.Upcoming;
}
public BrowseRowDef(String header, ViewQuery query) {
headerText = header;
this.staticHeight = true;
this.queryType = QueryType.Views;
}
public BrowseRowDef(String header, GetResumeItemsRequest query, int chunkSize, boolean preferParentThumb, boolean staticHeight, ChangeTriggerType[] changeTriggers) {
headerText = header;
this.resumeQuery = query;
@@ -147,12 +195,6 @@ public class BrowseRowDef {
this.changeTriggers = changeTriggers;
}
public BrowseRowDef(String header, GetSpecialsRequest query) {
headerText = header;
this.specialsQuery = query;
this.queryType = QueryType.Specials;
}
public int getChunkSize() {
return chunkSize;
}
@@ -163,43 +205,55 @@ public class BrowseRowDef {
return headerText;
}
public GetItemsRequest getQuery() {
public void setHeaderText(String headerText) {
this.headerText = headerText;
}
public ItemQuery getQuery() {
return query;
}
public GetNextUpRequest getNextUpQuery() {
public NextUpQuery getNextUpQuery() {
return nextUpQuery;
}
public GetLatestMediaRequest getLatestItemsQuery() { return latestItemsQuery; }
public LatestItemsQuery getLatestItemsQuery() { return latestItemsQuery; }
public GetSimilarItemsRequest getSimilarQuery() { return similarQuery; }
public SimilarItemsQuery getSimilarQuery() { return similarQuery; }
public QueryType getQueryType() {
return queryType;
}
public GetLiveTvChannelsRequest getTvChannelQuery() {
public SeasonQuery getSeasonQuery() { return seasonQuery; }
public UpcomingEpisodesQuery getUpcomingQuery() {
return upcomingQuery;
}
public LiveTvChannelQuery getTvChannelQuery() {
return tvChannelQuery;
}
public GetRecommendedProgramsRequest getProgramQuery() {
public RecommendedProgramQuery getProgramQuery() {
return programQuery;
}
public GetRecordingsRequest getRecordingQuery() { return recordingQuery; }
public RecordingQuery getRecordingQuery() { return recordingQuery; }
public boolean getPreferParentThumb() { return preferParentThumb; }
public GetArtistsRequest getArtistsQuery() { return artistsQuery; }
public GetAlbumArtistsRequest getAlbumArtistsQuery() { return albumArtistsQuery; }
public PersonsQuery getPersonsQuery() {
return personsQuery;
}
public GetSeriesTimersRequest getSeriesTimerQuery() { return seriesTimerQuery; }
public ArtistsQuery getArtistsQuery() { return artistsQuery; }
public AlbumArtistsQuery getAlbumArtistsQuery() { return albumArtistsQuery; }
public SeriesTimerQuery getSeriesTimerQuery() { return seriesTimerQuery; }
public GetResumeItemsRequest getResumeQuery() { return resumeQuery; }
public GetSpecialsRequest getSpecialsQuery() { return specialsQuery; }
public ChangeTriggerType[] getChangeTriggers() {
return changeTriggers;
}

View File

@@ -2,6 +2,8 @@ package org.jellyfin.androidtv.ui.browsing;
import org.jellyfin.androidtv.ui.livetv.TvManager;
import org.jellyfin.androidtv.ui.presentation.CardPresenter;
import org.jellyfin.androidtv.util.apiclient.LifecycleAwareResponse;
import org.jellyfin.apiclient.model.livetv.TimerQuery;
public class BrowseScheduleFragment extends EnhancedBrowseFragment {
@@ -12,6 +14,7 @@ public class BrowseScheduleFragment extends EnhancedBrowseFragment {
@Override
protected void setupQueries(final RowLoader rowLoader) {
TvManager.getScheduleRowsAsync(this, null, new CardPresenter(true), mRowsAdapter);
TvManager.getScheduleRowsAsync(requireContext(), new TimerQuery(), new CardPresenter(true), mRowsAdapter, new LifecycleAwareResponse<Integer>(getLifecycle()) {
});
}
}

View File

@@ -1,5 +1,7 @@
package org.jellyfin.androidtv.ui.browsing;
import static org.koin.java.KoinJavaComponent.inject;
import androidx.leanback.widget.ArrayObjectAdapter;
import androidx.leanback.widget.HeaderItem;
import androidx.leanback.widget.ListRow;
@@ -8,185 +10,478 @@ 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.Extras;
import org.jellyfin.androidtv.constant.LiveTvOption;
import org.jellyfin.androidtv.constant.QueryType;
import org.jellyfin.androidtv.data.querying.GetSeriesTimersRequest;
import org.jellyfin.androidtv.data.querying.StdItemQuery;
import org.jellyfin.androidtv.preference.UserPreferences;
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.sdk.model.api.BaseItemDto;
import org.jellyfin.sdk.model.api.BaseItemKind;
import org.jellyfin.sdk.model.api.CollectionType;
import org.jellyfin.sdk.model.api.TimerInfoDto;
import org.jellyfin.sdk.model.api.request.GetNextUpRequest;
import org.jellyfin.androidtv.util.apiclient.LifecycleAwareResponse;
import org.jellyfin.apiclient.interaction.ApiClient;
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.RecordingQuery;
import org.jellyfin.apiclient.model.livetv.SeriesTimerQuery;
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.querying.ItemFilter;
import org.jellyfin.apiclient.model.querying.ItemQuery;
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.time.LocalDateTime;
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) {
CollectionType type = mFolder != null && mFolder.getCollectionType() != null ? mFolder.getCollectionType() : CollectionType.UNKNOWN;
String type = mFolder.getCollectionType() != null ? mFolder.getCollectionType().toLowerCase() : "";
switch (type) {
case MOVIES:
itemType = BaseItemKind.MOVIE;
case CollectionType.Movies:
itemTypeString = "Movie";
//Resume
mRows.add(new BrowseRowDef(getString(R.string.lbl_continue_watching), BrowsingUtils.createResumeItemsRequest(mFolder.getId(), BaseItemKind.MOVIE), 0, new ChangeTriggerType[]{ChangeTriggerType.MoviePlayback}));
StdItemQuery resumeMovies = new StdItemQuery(new ItemFields[]{
ItemFields.PrimaryImageAspectRatio,
ItemFields.Overview,
ItemFields.ItemCounts,
ItemFields.DisplayPreferencesId,
ItemFields.ChildCount,
ItemFields.MediaStreams,
ItemFields.MediaSources
});
resumeMovies.setIncludeItemTypes(new String[]{"Movie"});
resumeMovies.setRecursive(true);
resumeMovies.setParentId(mFolder.getId().toString());
resumeMovies.setImageTypeLimit(1);
resumeMovies.setLimit(50);
resumeMovies.setCollapseBoxSetItems(false);
resumeMovies.setEnableTotalRecordCount(false);
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}));
//Latest
mRows.add(new BrowseRowDef(getString(R.string.lbl_latest), BrowsingUtils.createLatestMediaRequest(mFolder.getId()), new ChangeTriggerType[]{ChangeTriggerType.MoviePlayback, ChangeTriggerType.LibraryUpdated}));
LatestItemsQuery latestMovies = new LatestItemsQuery();
latestMovies.setFields(new ItemFields[]{
ItemFields.PrimaryImageAspectRatio,
ItemFields.Overview,
ItemFields.ChildCount,
ItemFields.MediaSources,
ItemFields.MediaStreams
});
latestMovies.setParentId(mFolder.getId().toString());
latestMovies.setLimit(50);
latestMovies.setImageTypeLimit(1);
mRows.add(new BrowseRowDef(getString(R.string.lbl_latest), latestMovies, new ChangeTriggerType[]{ChangeTriggerType.MoviePlayback, ChangeTriggerType.LibraryUpdated}));
//Favorites
mRows.add(new BrowseRowDef(getString(R.string.lbl_favorites), BrowsingUtils.createFavoriteItemsRequest(mFolder.getId(), BaseItemKind.MOVIE), 60, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated, ChangeTriggerType.FavoriteUpdate}));
StdItemQuery favorites = new StdItemQuery(new ItemFields[]{
ItemFields.PrimaryImageAspectRatio,
ItemFields.Overview,
ItemFields.ItemCounts,
ItemFields.DisplayPreferencesId,
ItemFields.ChildCount,
ItemFields.MediaStreams,
ItemFields.MediaSources
});
favorites.setIncludeItemTypes(new String[]{"Movie"});
favorites.setRecursive(true);
favorites.setParentId(mFolder.getId().toString());
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}));
//Collections
mRows.add(new BrowseRowDef(getString(R.string.lbl_collections), BrowsingUtils.createCollectionsRequest(mFolder.getId()), 60, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated}));
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}));
rowLoader.loadRows(mRows);
break;
case TVSHOWS:
itemType = BaseItemKind.SERIES;
case CollectionType.TvShows:
itemTypeString = "Series";
//Resume
mRows.add(new BrowseRowDef(getString(R.string.lbl_continue_watching), BrowsingUtils.createResumeItemsRequest(mFolder.getId(), BaseItemKind.EPISODE), 0, new ChangeTriggerType[]{ChangeTriggerType.TvPlayback}));
StdItemQuery resumeEpisodes = new StdItemQuery(new ItemFields[]{
ItemFields.PrimaryImageAspectRatio,
ItemFields.Overview,
ItemFields.ChildCount,
ItemFields.MediaSources,
ItemFields.MediaStreams
});
resumeEpisodes.setIncludeItemTypes(new String[]{"Episode"});
resumeEpisodes.setLimit(50);
resumeEpisodes.setParentId(mFolder.getId().toString());
resumeEpisodes.setRecursive(true);
resumeEpisodes.setImageTypeLimit(1);
resumeEpisodes.setFilters(new ItemFilter[]{ItemFilter.IsResumable});
resumeEpisodes.setSortBy(new String[]{ItemSortBy.DatePlayed});
resumeEpisodes.setSortOrder(SortOrder.Descending);
mRows.add(new BrowseRowDef(getString(R.string.lbl_continue_watching), resumeEpisodes, 0, new ChangeTriggerType[]{ChangeTriggerType.TvPlayback}));
//Next up
GetNextUpRequest getNextUpRequest = BrowsingUtils.createGetNextUpRequest(mFolder.getId());
mRows.add(new BrowseRowDef(getString(R.string.lbl_next_up), getNextUpRequest, new ChangeTriggerType[]{ChangeTriggerType.TvPlayback}));
NextUpQuery nextUpQuery = new NextUpQuery();
nextUpQuery.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
nextUpQuery.setLimit(50);
nextUpQuery.setParentId(mFolder.getId().toString());
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}));
//Premieres
if (KoinJavaComponent.<UserPreferences>get(UserPreferences.class).get(UserPreferences.Companion.getPremieresEnabled())) {
mRows.add(new BrowseRowDef(getString(R.string.lbl_new_premieres), BrowsingUtils.createPremieresRequest(mFolder.getId()), 0, true, true, new ChangeTriggerType[]{ChangeTriggerType.TvPlayback}, QueryType.Premieres));
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());
newQuery.setIncludeItemTypes(new String[]{"Episode"});
newQuery.setParentId(mFolder.getId().toString());
newQuery.setRecursive(true);
newQuery.setIsVirtualUnaired(false);
newQuery.setIsMissing(false);
newQuery.setImageTypeLimit(1);
newQuery.setFilters(new ItemFilter[]{ItemFilter.IsUnplayed});
newQuery.setSortBy(new String[]{ItemSortBy.DateCreated});
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));
}
//Latest content added
mRows.add(new BrowseRowDef(getString(R.string.lbl_latest), BrowsingUtils.createLatestMediaRequest(mFolder.getId(), BaseItemKind.EPISODE, true), new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated}));
LatestItemsQuery latestSeries = new LatestItemsQuery();
latestSeries.setFields(new ItemFields[]{
ItemFields.PrimaryImageAspectRatio,
ItemFields.Overview,
ItemFields.ChildCount,
ItemFields.MediaSources,
ItemFields.MediaStreams
});
latestSeries.setIncludeItemTypes(new String[]{"Episode"});
latestSeries.setGroupItems(true);
latestSeries.setParentId(mFolder.getId().toString());
latestSeries.setLimit(50);
latestSeries.setImageTypeLimit(1);
mRows.add(new BrowseRowDef(getString(R.string.lbl_latest), latestSeries, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated}));
//Favorites
mRows.add(new BrowseRowDef(getString(R.string.lbl_favorites), BrowsingUtils.createFavoriteItemsRequest(mFolder.getId(), BaseItemKind.SERIES), 60, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated, ChangeTriggerType.FavoriteUpdate}));
StdItemQuery tvFavorites = new StdItemQuery();
tvFavorites.setIncludeItemTypes(new String[]{"Series"});
tvFavorites.setRecursive(true);
tvFavorites.setParentId(mFolder.getId().toString());
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}));
rowLoader.loadRows(mRows);
break;
case MUSIC:
case CollectionType.Music:
//Latest
mRows.add(new BrowseRowDef(getString(R.string.lbl_latest), BrowsingUtils.createLatestMediaRequest(mFolder.getId(), BaseItemKind.AUDIO, true), new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated}));
LatestItemsQuery latestAlbums = new LatestItemsQuery();
latestAlbums.setFields(new ItemFields[]{
ItemFields.PrimaryImageAspectRatio,
ItemFields.Overview,
ItemFields.ChildCount
});
latestAlbums.setIncludeItemTypes(new String[]{"Audio"});
latestAlbums.setGroupItems(true);
latestAlbums.setImageTypeLimit(1);
latestAlbums.setParentId(mFolder.getId().toString());
latestAlbums.setLimit(50);
mRows.add(new BrowseRowDef(getString(R.string.lbl_latest), latestAlbums, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated}));
//Last Played
mRows.add(new BrowseRowDef(getString(R.string.lbl_last_played), BrowsingUtils.createLastPlayedRequest(mFolder.getId()), 0, false, true, new ChangeTriggerType[]{ChangeTriggerType.MusicPlayback, ChangeTriggerType.LibraryUpdated}));
StdItemQuery lastPlayed = new StdItemQuery();
lastPlayed.setIncludeItemTypes(new String[]{"Audio"});
lastPlayed.setRecursive(true);
lastPlayed.setParentId(mFolder.getId().toString());
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}));
//Favorites
mRows.add(new BrowseRowDef(getString(R.string.lbl_favorites), BrowsingUtils.createFavoriteItemsRequest(mFolder.getId(), BaseItemKind.MUSIC_ALBUM), 60, false, true, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated, ChangeTriggerType.FavoriteUpdate}));
StdItemQuery favAlbums = new StdItemQuery();
favAlbums.setIncludeItemTypes(new String[]{"MusicAlbum", "MusicArtist"});
favAlbums.setRecursive(true);
favAlbums.setParentId(mFolder.getId().toString());
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}));
//AudioPlaylists
mRows.add(new BrowseRowDef(getString(R.string.lbl_playlists), BrowsingUtils.createPlaylistsRequest(), 60, false, true, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated}, QueryType.AudioPlaylists));
StdItemQuery playlists = new StdItemQuery(new ItemFields[]{
ItemFields.PrimaryImageAspectRatio,
ItemFields.CumulativeRunTimeTicks,
ItemFields.ChildCount
});
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));
rowLoader.loadRows(mRows);
break;
case LIVETV:
case CollectionType.LiveTv:
isLiveTvLibrary = true;
showViews = true;
//On now
mRows.add(new BrowseRowDef(getString(R.string.lbl_on_now), BrowsingUtils.createLiveTVOnNowRequest()));
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.setImageTypeLimit(1);
onNow.setEnableTotalRecordCount(false);
onNow.setLimit(150);
mRows.add(new BrowseRowDef(getString(R.string.lbl_on_now), onNow));
//Upcoming
mRows.add(new BrowseRowDef(getString(R.string.lbl_coming_up), BrowsingUtils.createLiveTVUpcomingRequest()));
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.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));
//Fav Channels
mRows.add(new BrowseRowDef(getString(R.string.lbl_favorite_channels), BrowsingUtils.createLiveTVChannelsRequest(true)));
LiveTvChannelQuery favTv = new LiveTvChannelQuery();
favTv.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
favTv.setEnableFavoriteSorting(true);
favTv.setIsFavorite(true);
mRows.add(new BrowseRowDef(getString(R.string.lbl_favorite_channels), favTv));
//Other Channels
mRows.add(new BrowseRowDef(getString(R.string.lbl_other_channels), BrowsingUtils.createLiveTVChannelsRequest(false)));
LiveTvChannelQuery otherTv = new LiveTvChannelQuery();
otherTv.setUserId(KoinJavaComponent.<UserRepository>get(UserRepository.class).getCurrentUser().getValue().getId().toString());
otherTv.setIsFavorite(false);
mRows.add(new BrowseRowDef(getString(R.string.lbl_other_channels), otherTv));
//Latest Recordings
BrowseViewFragmentHelperKt.getLiveTvRecordingsAndTimers(this, (recordings, timers) -> {
List<BaseItemDto> nearTimers = new ArrayList<>();
LocalDateTime next24 = LocalDateTime.now().plusDays(1);
//Get scheduled items for next 24 hours
for (TimerInfoDto timer : timers.getItems()) {
if (timer.getStartDate().isBefore(next24)) {
nearTimers.add(BrowseViewFragmentHelperKt.getTimerProgramInfo(timer));
}
}
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.setEnableImages(true);
recordings.setLimit(40);
if (recordings.getTotalRecordCount() > 0) {
List<BaseItemDto> dayItems = new ArrayList<>();
List<BaseItemDto> weekItems = new ArrayList<>();
//Do a straight query and then split the returned items into logical groups
apiClient.getValue().GetLiveTvRecordingsAsync(recordings, new LifecycleAwareResponse<ItemsResult>(getLifecycle()) {
@Override
public void onResponse(ItemsResult response) {
if (!getActive()) return;
LocalDateTime past24 = LocalDateTime.now().minusDays(1);
LocalDateTime pastWeek = LocalDateTime.now().minusWeeks(1);
for (BaseItemDto item : recordings.getItems()) {
if (item.getDateCreated() != null) {
if (item.getDateCreated().isAfter(past24)) {
dayItems.add(item);
} else if (item.getDateCreated().isAfter(pastWeek)) {
weekItems.add(item);
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()) {
@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
for (TimerInfoDto timer : response.getItems()) {
if (TimeUtils.convertToLocalDate(timer.getStartDate()).getTime() <= next24) {
BaseItemDto programInfo = timer.getProgramInfo();
if (programInfo == null) {
programInfo = new BaseItemDto();
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());
programInfo.setBaseItemType(BaseItemType.Program);
programInfo.setTimerId(timer.getId());
programInfo.setSeriesTimerId(timer.getSeriesTimerId());
programInfo.setStartDate(timer.getStartDate());
programInfo.setEndDate(timer.getEndDate());
}
programInfo.setLocationType(LocationType.Virtual);
nearTimers.add(programInfo);
}
}
if (recordingsResponse.getTotalRecordCount() > 0) {
List<BaseItemDto> dayItems = new ArrayList<>();
List<BaseItemDto> weekItems = new ArrayList<>();
long past24 = System.currentTimeMillis() - ticks24;
long pastWeek = System.currentTimeMillis() - (ticks24 * 7);
for (BaseItemDto item : recordingsResponse.getItems()) {
if (item.getDateCreated() != null) {
if (TimeUtils.convertToLocalDate(item.getDateCreated()).getTime() >= past24) {
dayItems.add(item);
} else if (TimeUtils.convertToLocalDate(item.getDateCreated()).getTime() >= pastWeek) {
weekItems.add(item);
}
}
}
//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.setEnableImages(true);
mRows.add(new BrowseRowDef(getString(R.string.lbl_recent_recordings), recordings, 50));
rowLoader.loadRows(mRows);
//Now insert our smart rows
if (weekItems.size() > 0) {
ItemRowAdapter weekAdapter = new ItemRowAdapter(requireContext(), 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);
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);
dayAdapter.Retrieve();
ListRow dayRow = new ListRow(new HeaderItem("Past 24 Hours"), dayAdapter);
mRowsAdapter.add(0, dayRow);
}
} else {
// no recordings
rowLoader.loadRows(mRows);
if (nearTimers.size() > 0) {
ItemRowAdapter scheduledAdapter = new ItemRowAdapter(requireContext(), nearTimers, mCardPresenter, mRowsAdapter, true);
scheduledAdapter.Retrieve();
ListRow scheduleRow = new ListRow(new HeaderItem("Scheduled in Next 24 Hours"), scheduledAdapter);
mRowsAdapter.add(0, scheduleRow);
} else {
mTitle.setText(R.string.lbl_no_recordings);
}
}
}
}
//First put all recordings in and retrieve
//All Recordings
mRows.add(new BrowseRowDef(getString(R.string.lbl_recent_recordings), BrowsingUtils.createLiveTVRecordingsRequest(), 50));
rowLoader.loadRows(mRows);
//Now insert our smart rows
if (!weekItems.isEmpty()) {
ItemRowAdapter weekAdapter = new ItemRowAdapter(requireContext(), weekItems, mCardPresenter, mRowsAdapter, true);
weekAdapter.Retrieve();
ListRow weekRow = new ListRow(new HeaderItem(getString(R.string.past_week)), weekAdapter);
mRowsAdapter.add(0, weekRow);
}
if (!nearTimers.isEmpty()) {
ItemRowAdapter scheduledAdapter = new ItemRowAdapter(requireContext(), nearTimers, mCardPresenter, mRowsAdapter, true);
scheduledAdapter.Retrieve();
ListRow scheduleRow = new ListRow(new HeaderItem(getString(R.string.scheduled_in_next_24_hours)), scheduledAdapter);
mRowsAdapter.add(0, scheduleRow);
}
if (!dayItems.isEmpty()) {
ItemRowAdapter dayAdapter = new ItemRowAdapter(requireContext(), dayItems, mCardPresenter, mRowsAdapter, true);
dayAdapter.Retrieve();
ListRow dayRow = new ListRow(new HeaderItem(getString(R.string.past_24_hours)), dayAdapter);
mRowsAdapter.add(0, dayRow);
}
} else {
// no recordings
rowLoader.loadRows(mRows);
if (!nearTimers.isEmpty()) {
ItemRowAdapter scheduledAdapter = new ItemRowAdapter(requireContext(), nearTimers, mCardPresenter, mRowsAdapter, true);
scheduledAdapter.Retrieve();
ListRow scheduleRow = new ListRow(new HeaderItem(getString(R.string.scheduled_in_next_24_hours)), scheduledAdapter);
mRowsAdapter.add(0, scheduleRow);
} else {
mTitle.setText(R.string.lbl_no_recordings);
}
});
}
return null;
}, exception -> {
Utils.showToast(getContext(), exception.getLocalizedMessage());
return null;
@Override
public void onError(Exception exception) {
if (!getActive()) return;
Utils.showToast(getContext(), exception.getLocalizedMessage());
}
});
break;
case "seriestimers":
mRows.add(new BrowseRowDef(getString(R.string.lbl_series_recordings), new SeriesTimerQuery()));
rowLoader.loadRows(mRows);
break;
default:
boolean isRecordingsView = getArguments().getBoolean(Extras.IsLiveTvSeriesRecordings, false);
if (isRecordingsView) {
mRows.add(new BrowseRowDef(getString(R.string.lbl_series_recordings), GetSeriesTimersRequest.INSTANCE));
rowLoader.loadRows(mRows);
}
// 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();
ItemQuery query = new ItemQuery();
query.setParentId(mFolder.getId().toString());
query.setUserId(userId.toString());
query.setImageTypeLimit(1);
query.setSortBy(new String[]{ItemSortBy.SortName});
apiClient.getValue().GetItemsAsync(query, new LifecycleAwareResponse<ItemsResult>(getLifecycle()) {
@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());
rows.add(new BrowseRowDef(item.getName(), rowQuery, 60, new ChangeTriggerType[]{ChangeTriggerType.LibraryUpdated}));
}
}
rowLoader.loadRows(rows);
}
@Override
public void onError(Exception exception) {
Timber.e(exception, "Failed to get items");
}
});
break;
}
}

View File

@@ -1,77 +0,0 @@
package org.jellyfin.androidtv.ui.browsing
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.LocationType
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.TimerInfoDto
import org.jellyfin.sdk.model.api.TimerInfoDtoQueryResult
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import org.koin.android.ext.android.inject
fun EnhancedBrowseFragment.getLiveTvRecordingsAndTimers(
callback: (recordings: BaseItemDtoQueryResult, timers: TimerInfoDtoQueryResult) -> Unit,
errorCallback: (exception: Throwable) -> Unit,
) {
val api by inject<ApiClient>()
lifecycleScope.launch {
runCatching {
val recordings by api.liveTvApi.getRecordings(
fields = setOf(
ItemFields.OVERVIEW,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
),
enableImages = true,
limit = 40,
)
val timers by api.liveTvApi.getTimers()
recordings to timers
}.fold(
onSuccess = { (recordings, timers) -> callback(recordings, timers) },
onFailure = { exception -> errorCallback(exception) }
)
}
}
fun EnhancedBrowseFragment.getLiveTvTimers(
callback: (timers: TimerInfoDtoQueryResult) -> Unit,
errorCallback: (exception: Throwable) -> Unit,
) {
val api by inject<ApiClient>()
lifecycleScope.launch {
runCatching {
api.liveTvApi.getTimers().content
}.fold(
onSuccess = { timers -> callback(timers) },
onFailure = { exception -> errorCallback(exception) }
)
}
}
fun getTimerProgramInfo(timer: TimerInfoDto): BaseItemDto {
val programInfo = timer.programInfo ?: BaseItemDto(
id = requireNotNull(timer.id?.toUUIDOrNull()),
channelName = timer.channelName,
name = timer.name.orEmpty(),
type = BaseItemKind.PROGRAM,
timerId = timer.id,
seriesTimerId = timer.seriesTimerId,
startDate = timer.startDate,
endDate = timer.endDate,
mediaType = MediaType.UNKNOWN,
)
return programInfo.copy(
locationType = LocationType.VIRTUAL,
)
}

View File

@@ -1,435 +0,0 @@
package org.jellyfin.androidtv.ui.browsing
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemFilter
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
import org.jellyfin.sdk.model.api.request.GetSeasonsRequest
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
import org.jellyfin.sdk.model.api.request.GetUpcomingEpisodesRequest
import timber.log.Timber
import java.util.UUID
object BrowsingUtils {
@JvmStatic
fun getRandomItem(
api: ApiClient,
lifecycle: LifecycleOwner,
library: BaseItemDto,
type: BaseItemKind,
callback: (item: BaseItemDto?) -> Unit
) {
lifecycle.lifecycleScope.launch {
try {
val result by api.itemsApi.getItems(
parentId = library.id,
includeItemTypes = setOf(type),
recursive = true,
sortBy = setOf(ItemSortBy.RANDOM),
limit = 1,
)
callback(result.items?.firstOrNull())
} catch (error: ApiClientException) {
Timber.w(error, "Failed to retrieve random item")
callback(null)
}
}
}
@JvmStatic
fun createGetNextUpRequest(parentId: UUID) = GetNextUpRequest(
limit = 50,
parentId = parentId,
imageTypeLimit = 1,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.CHILD_COUNT,
ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_STREAMS,
)
)
@JvmStatic
fun createSeriesGetNextUpRequest(parentId: UUID) = GetNextUpRequest(
seriesId = parentId,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
)
)
@JvmStatic
@JvmOverloads
fun createLatestMediaRequest(
parentId: UUID,
itemType: BaseItemKind? = null,
groupItems: Boolean? = null
) = GetLatestMediaRequest(
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.CHILD_COUNT,
ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_STREAMS,
),
parentId = parentId,
limit = 50,
imageTypeLimit = 1,
includeItemTypes = itemType?.let(::setOf),
groupItems = groupItems,
)
@JvmStatic
fun createSeasonsRequest(seriesId: UUID) = GetSeasonsRequest(
seriesId = seriesId,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
),
)
@JvmStatic
fun createUpcomingEpisodesRequest(parentId: UUID) = GetUpcomingEpisodesRequest(
parentId = parentId,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
),
)
@JvmStatic
fun createSimilarItemsRequest(itemId: UUID) = GetSimilarItemsRequest(
itemId = itemId,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
),
limit = 20,
)
@JvmStatic
fun createLiveTVOnNowRequest() = GetRecommendedProgramsRequest(
isAiring = true,
fields = setOf(
ItemFields.OVERVIEW,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHANNEL_INFO,
ItemFields.CHILD_COUNT,
),
imageTypeLimit = 1,
enableTotalRecordCount = false,
limit = 150,
)
@JvmStatic
fun createLiveTVUpcomingRequest() = GetRecommendedProgramsRequest(
isAiring = false,
hasAired = false,
fields = setOf(
ItemFields.OVERVIEW,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHANNEL_INFO,
ItemFields.CHILD_COUNT,
),
imageTypeLimit = 1,
enableTotalRecordCount = false,
limit = 150,
)
@JvmStatic
@JvmOverloads
fun createLiveTVRecordingsRequest(limit: Int? = null) = GetRecordingsRequest(
fields = setOf(
ItemFields.OVERVIEW,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
),
enableImages = true,
limit = limit,
)
@JvmStatic
fun createLiveTVMovieRecordingsRequest() = GetRecordingsRequest(
fields = setOf(
ItemFields.OVERVIEW,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
),
enableImages = true,
limit = 60,
isMovie = true,
)
@JvmStatic
fun createLiveTVSeriesRecordingsRequest() = GetRecordingsRequest(
fields = setOf(
ItemFields.OVERVIEW,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
),
enableImages = true,
limit = 60,
isSeries = true,
)
@JvmStatic
fun createLiveTVSportsRecordingsRequest() = GetRecordingsRequest(
fields = setOf(
ItemFields.OVERVIEW,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
),
enableImages = true,
limit = 60,
isSports = true,
)
@JvmStatic
fun createLiveTVKidsRecordingsRequest() = GetRecordingsRequest(
fields = setOf(
ItemFields.OVERVIEW,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
),
enableImages = true,
limit = 60,
isKids = true,
)
@JvmStatic
fun createLiveTVChannelsRequest(isFavorite: Boolean) = GetLiveTvChannelsRequest(
isFavorite = isFavorite,
)
@JvmStatic
fun createAlbumArtistsRequest(parentId: UUID) = GetAlbumArtistsRequest(
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.ITEM_COUNTS,
ItemFields.CHILD_COUNT,
),
parentId = parentId,
)
@JvmStatic
fun createArtistsRequest(parentId: UUID) = GetArtistsRequest(
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.ITEM_COUNTS,
ItemFields.CHILD_COUNT,
),
parentId = parentId,
)
@JvmStatic
fun createPersonItemsRequest(personId: UUID, itemType: BaseItemKind) = GetItemsRequest(
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
),
personIds = setOf(personId),
recursive = true,
includeItemTypes = setOf(itemType),
sortBy = setOf(ItemSortBy.SORT_NAME),
)
@JvmStatic
fun createArtistItemsRequest(artistId: UUID, itemType: BaseItemKind) = GetItemsRequest(
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
),
artistIds = setOf(artistId),
recursive = true,
includeItemTypes = setOf(itemType),
sortBy = setOf(ItemSortBy.SORT_NAME),
)
@JvmStatic
fun createNextEpisodesRequest(seasonId: UUID, indexNumber: Int) = GetItemsRequest(
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.ITEM_COUNTS,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
),
parentId = seasonId,
includeItemTypes = setOf(BaseItemKind.EPISODE),
startIndex = indexNumber,
limit = 20,
)
@JvmStatic
fun createResumeItemsRequest(parentId: UUID, itemType: BaseItemKind) = GetItemsRequest(
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.ITEM_COUNTS,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
ItemFields.MEDIA_STREAMS,
ItemFields.MEDIA_SOURCES,
),
includeItemTypes = setOf(itemType),
recursive = true,
parentId = parentId,
imageTypeLimit = 1,
limit = 50,
collapseBoxSetItems = false,
enableTotalRecordCount = false,
filters = setOf(ItemFilter.IS_RESUMABLE),
sortBy = setOf(ItemSortBy.DATE_PLAYED),
sortOrder = setOf(SortOrder.DESCENDING),
)
@JvmStatic
fun createFavoriteItemsRequest(parentId: UUID, itemType: BaseItemKind) = GetItemsRequest(
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.ITEM_COUNTS,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
ItemFields.MEDIA_STREAMS,
ItemFields.MEDIA_SOURCES,
),
includeItemTypes = setOf(itemType),
recursive = true,
parentId = parentId,
imageTypeLimit = 1,
filters = setOf(ItemFilter.IS_FAVORITE),
sortBy = setOf(ItemSortBy.SORT_NAME),
)
@JvmStatic
fun createCollectionsRequest(parentId: UUID) = GetItemsRequest(
fields = setOf(ItemFields.CHILD_COUNT),
includeItemTypes = setOf(BaseItemKind.BOX_SET),
recursive = true,
imageTypeLimit = 1,
parentId = parentId,
sortBy = setOf(ItemSortBy.SORT_NAME),
)
@JvmStatic
fun createPremieresRequest(parentId: UUID) = GetItemsRequest(
fields = setOf(
ItemFields.DATE_CREATED,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.CHILD_COUNT,
),
includeItemTypes = setOf(BaseItemKind.EPISODE),
parentId = parentId,
indexNumber = 1,
recursive = true,
isMissing = false,
imageTypeLimit = 1,
filters = setOf(ItemFilter.IS_UNPLAYED),
sortBy = setOf(ItemSortBy.DATE_CREATED),
sortOrder = setOf(SortOrder.DESCENDING),
enableTotalRecordCount = false,
limit = 300,
)
@JvmStatic
fun createLastPlayedRequest(parentId: UUID) = GetItemsRequest(
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.ITEM_COUNTS,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
),
includeItemTypes = setOf(BaseItemKind.AUDIO),
recursive = true,
parentId = parentId,
imageTypeLimit = 1,
filters = setOf(ItemFilter.IS_PLAYED),
sortBy = setOf(ItemSortBy.DATE_PLAYED),
sortOrder = setOf(SortOrder.DESCENDING),
enableTotalRecordCount = false,
limit = 50,
)
@JvmStatic
fun createPlaylistsRequest() = GetItemsRequest(
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CUMULATIVE_RUN_TIME_TICKS,
ItemFields.CHILD_COUNT,
),
includeItemTypes = setOf(BaseItemKind.PLAYLIST),
imageTypeLimit = 1,
recursive = true,
sortBy = setOf(ItemSortBy.DATE_CREATED),
sortOrder = setOf(SortOrder.DESCENDING),
)
@JvmStatic
fun createBrowseGridItemsRequest(parent: BaseItemDto): GetItemsRequest {
val baseRequest = GetItemsRequest(
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_STREAMS,
ItemFields.DISPLAY_PREFERENCES_ID,
),
parentId = parent.id,
)
if (parent.type == BaseItemKind.USER_VIEW || parent.type == BaseItemKind.COLLECTION_FOLDER) {
return when (parent.collectionType) {
CollectionType.MOVIES -> baseRequest.copy(
includeItemTypes = setOf(BaseItemKind.MOVIE),
recursive = true,
)
CollectionType.TVSHOWS -> baseRequest.copy(
includeItemTypes = setOf(BaseItemKind.SERIES),
recursive = true,
)
CollectionType.BOXSETS -> baseRequest.copy(
includeItemTypes = setOf(BaseItemKind.BOX_SET),
parentId = null,
recursive = true,
)
CollectionType.MUSIC -> baseRequest.copy(
includeItemTypes = setOf(BaseItemKind.MUSIC_ALBUM),
recursive = true,
)
else -> baseRequest
}
}
return baseRequest
}
}

View File

@@ -1,11 +1,9 @@
package org.jellyfin.androidtv.ui.browsing
import org.jellyfin.androidtv.data.querying.StdItemQuery
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.genresApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.constant.ItemSortBy
import org.koin.android.ext.android.inject
class ByGenreFragment : BrowseFolderFragment() {
@@ -18,25 +16,19 @@ class ByGenreFragment : BrowseFolderFragment() {
// Get all genres for this folder
val genresResponse by apiClient.genresApi.getGenres(
parentId = folder?.id,
sortBy = setOf(ItemSortBy.SORT_NAME),
sortBy = setOf(ItemSortBy.SortName),
userId = apiClient.userId,
)
for (genre in genresResponse.items) {
val itemsRequest = GetItemsRequest(
parentId = folder?.id,
sortBy = setOf(ItemSortBy.SORT_NAME),
includeItemTypes = includeType?.let(BaseItemKind::fromNameOrNull)?.let(::setOf),
genres = setOf(genre.name.orEmpty()),
recursive = true,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.ITEM_COUNTS,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
),
)
rows.add(BrowseRowDef(genre.name, itemsRequest, 40))
for (genre in genresResponse.items.orEmpty()) {
val itemsQuery = StdItemQuery().apply {
parentId = folder?.id.toString()
sortBy = arrayOf(ItemSortBy.SortName)
includeType?.let { includeItemTypes = arrayOf(it) }
genres = arrayOf(genre.name)
recursive = true
}
rows.add(BrowseRowDef(genre.name, itemsQuery, 40))
}
rowLoader.loadRows(rows)

View File

@@ -1,10 +1,8 @@
package org.jellyfin.androidtv.ui.browsing
import org.jellyfin.androidtv.R
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.androidtv.data.querying.StdItemQuery
import org.jellyfin.sdk.model.constant.ItemSortBy
class ByLetterFragment : BrowseFolderFragment() {
override suspend fun setupQueries(rowLoader: RowLoader) {
@@ -14,41 +12,27 @@ class ByLetterFragment : BrowseFolderFragment() {
val letters = getString(R.string.byletter_letters)
// Add a '#' item
val numbersItemsRequest = GetItemsRequest(
parentId = folder?.id,
sortBy = setOf(ItemSortBy.SORT_NAME),
includeItemTypes = includeType?.let(BaseItemKind::fromNameOrNull)?.let(::setOf),
nameLessThan = letters.substring(0, 1),
recursive = true,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.ITEM_COUNTS,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
),
)
val numbersQuery = StdItemQuery().apply {
parentId = folder?.id?.toString()
sortBy = arrayOf(ItemSortBy.SortName)
includeType?.let { includeItemTypes = arrayOf(it) }
nameLessThan = letters.substring(0, 1)
recursive = true
}
rows.add(BrowseRowDef("#", numbersItemsRequest, 40))
rows.add(BrowseRowDef("#", numbersQuery, 40))
// Add all the defined letters
for (letter in letters.toCharArray()) {
val letterItemsRequest = GetItemsRequest(
parentId = folder?.id,
sortBy = setOf(ItemSortBy.SORT_NAME),
includeItemTypes = includeType?.let(BaseItemKind::fromNameOrNull)?.let(::setOf),
nameStartsWith = letter.toString(),
recursive = true,
fields = setOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.OVERVIEW,
ItemFields.ITEM_COUNTS,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.CHILD_COUNT,
),
)
val letterQuery = StdItemQuery().apply {
parentId = folder?.id?.toString()
sortBy = arrayOf(ItemSortBy.SortName)
includeType?.let { includeItemTypes = arrayOf(it) }
nameStartsWith = letter.toString()
recursive = true
}
rows.add(BrowseRowDef(letter.toString(), letterItemsRequest, 40))
rows.add(BrowseRowDef(letter.toString(), letterQuery, 40))
}
rowLoader.loadRows(rows)

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