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
822 changed files with 25530 additions and 39452 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

2
.gitattributes vendored
View File

@@ -5,5 +5,3 @@
CONTRIBUTORS.md merge=union
app/src/main/res/values*/strings.xml merge=union
design/src/main/kotlin/token/* linguist-generated

View File

@@ -4,24 +4,6 @@ labels:
- bug
- playback
body:
- type: checkboxes
id: before-posting
attributes:
label: "This issue respects the following points:"
description: All conditions are **required**.
options:
- label: I have discussed this issue in the [Jellyfin forum](https://forum.jellyfin.org/) and/or [troubleshooting chat](https://jellyfin.org/contact) before opening this issue.
required: true
- label: This issue is **not** already reported on [GitHub](https://github.com/jellyfin/jellyfin-androidtv/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_.
required: true
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
required: true
- label: This report addresses only a single issue; If you encounter multiple issues, kindly create separate reports for each one.
required: true
- type: markdown
attributes:
value: |
## Bug information
- type: textarea
id: description
attributes:
@@ -38,38 +20,10 @@ body:
Instead, I expect …
validations:
required: true
- type: textarea
id: mediainfo
attributes:
label: Media info of the file
description: |
Please share the media information for the file causing issues. You can use a variety of tools to retrieve this information.
- Use ffprobe (`ffprobe ./file.mp4`)
- Copy the media info from the web interface
placeholder: Paste media info…
render: shell
- type: textarea
id: mediacapabilitiesreport
attributes:
label: Media capabilities report
description: |
Please share the media capability report generated from within the app. To do some follow these steps:
1. Open the Jellyfin for Android TV app
2. Connect to a server **that you have admin access to**
3. Go to Preferences -> Playback -> Advanced playback preferences
4. On the bottom, press the "Report playback capabilities to server" button
5. Go to the logs in your Jellyfin dashboard (in your browser)
6. Open the newest log file, it should be named similar to `upload_Jellyfin Android TV[...].log`
7. Press the "Download" button and paste the file contents
placeholder: Paste capability report contents…
- type: markdown
attributes:
value: |
## Logs
- type: textarea
id: logs
attributes:
label: Client logs
label: Logs
description: |
Please paste your crash logs here if applicable. You can find these in your servers dashboard under "logs".
The file name should start with "upload_org.jellyfin.androidtv".
@@ -83,34 +37,31 @@ body:
Please paste your FFmpeg logs here. You can find these in your servers dashboard under "logs".
placeholder: Paste logs…
render: shell
- type: markdown
- type: textarea
id: mediainfo
attributes:
value: |
## Environment
label: Media info of the file
description: |
Please share the media information for the file causing issues. You can use a variety of tools to retrieve this information.
- Use ffprobe (`ffprobe ./file.mp4`)
- Use the Media Info tool (set to text format, download here: https://mediaarea.net/en/MediaInfo)
- Copy the media info from the web interface
placeholder: Paste media info…
render: shell
- type: input
id: app-version
attributes:
label: Application version
description: The version of the installed Jellyfin Android TV app.
placeholder: 0.18.2
placeholder: 0.14.0
validations:
required: true
- type: dropdown
id: installation-source
attributes:
label: Where did you install the app from?
description: Choose the appropriate app store or installation method.
options:
- Google Play Store
- Amazon Appstore
- F-Droid
- Sideloaded APK
- type: input
id: device-info
attributes:
label: Device information
description: Manufacturer and model
placeholder: Nvidia Shield TV Pro (2019), Fire TV Stick HD (2020)
placeholder: Nvidia Shield Pro (2017), Amazon Fire TV Stick v1 (2014)
validations:
required: true
- type: input
@@ -118,7 +69,7 @@ body:
attributes:
label: Android version
description: Version of the OS and other information (e.g. custom ROM / OEM skin)
placeholder: Android 13, Fire OS 7
placeholder: Android 9, Fire OS 7
validations:
required: true
- type: input
@@ -126,18 +77,6 @@ body:
attributes:
label: Jellyfin server version
description: If on unstable, please specify the commit hash.
placeholder: 10.10.2
placeholder: 10.8.1
validations:
required: true
- type: markdown
attributes:
value: |
## Additional
- type: input
id: other-sources
attributes:
label: Other sources
description: Please link the related forum topics or chat messages that prompted you open this issue.
placeholder: https://forum.jellyfin.org/…
validations:
required: false

View File

@@ -3,24 +3,6 @@ description: Create a bug report
labels:
- bug
body:
- type: checkboxes
id: before-posting
attributes:
label: "This issue respects the following points:"
description: All conditions are **required**.
options:
- label: I have discussed this issue in the [Jellyfin forum](https://forum.jellyfin.org/) and/or [troubleshooting chat](https://jellyfin.org/contact) before opening this issue.
required: true
- label: This issue is **not** already reported on [GitHub](https://github.com/jellyfin/jellyfin-androidtv/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_.
required: true
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
required: true
- label: This report addresses only a single issue; If you encounter multiple issues, kindly create separate reports for each one.
required: true
- type: markdown
attributes:
value: |
## Bug information
- type: textarea
id: description
attributes:
@@ -46,16 +28,12 @@ body:
The file name should start with "upload_org.jellyfin.androidtv".
Make sure that they don't contain any sensitive information like server URL, auth tokens or passwords.
placeholder: Paste logs…
- type: markdown
attributes:
value: |
## Environment
- type: input
id: app-version
attributes:
label: Application version
description: The version of the installed Jellyfin Android TV app.
placeholder: 0.18.2
placeholder: 0.14.0
validations:
required: true
- type: dropdown
@@ -64,16 +42,15 @@ body:
label: Where did you install the app from?
description: Choose the appropriate app store or installation method.
options:
- Google Play Store
- Google Play
- Amazon Appstore
- F-Droid
- Sideloaded APK
- type: input
id: device-info
attributes:
label: Device information
description: Manufacturer and model
placeholder: Nvidia Shield TV Pro (2019), Fire TV Stick HD (2020)
placeholder: Nvidia Shield Pro (2017), Amazon Fire TV Stick v1 (2014)
validations:
required: true
- type: input
@@ -81,7 +58,7 @@ body:
attributes:
label: Android version
description: Version of the OS and other information (e.g. custom ROM / OEM skin)
placeholder: Android 13, Fire OS 7
placeholder: Android 9, Fire OS 7
validations:
required: true
- type: input
@@ -89,18 +66,6 @@ body:
attributes:
label: Jellyfin server version
description: If on unstable, please specify the commit hash.
placeholder: 10.10.2
placeholder: 10.8.1
validations:
required: true
- type: markdown
attributes:
value: |
## Additional
- type: input
id: other-sources
attributes:
label: Other sources
description: Please link the related forum topics or chat messages that prompted you open this issue.
placeholder: https://forum.jellyfin.org/…
validations:
required: false

View File

@@ -3,54 +3,12 @@ description: Request a new feature
labels:
- enhancement
body:
- type: checkboxes
id: before-posting
attributes:
label: "This request respects the following points:"
description: All conditions are **required**.
options:
- label: This request is **not** already on [GitHub](https://github.com/jellyfin/jellyfin-androidtv/labels/enhancement) _(I've searched it)_.
required: true
- label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
required: true
- type: textarea
id: description
attributes:
label: Problem description
description: Describe the issue you are experiencing or the gap you are trying to address.
placeholder: |
The Android TV app does not support displaying lyrics, while the Jellyfin web interface does.
This creates an inconsistent user experience across platforms.
label: Describe the feature you'd like
description: |
A clear and concise description of what you want to request.
You can also attach screenshots or screen recordings to help explain your request.
validations:
required: true
- type: textarea
attributes:
label: Proposed solution
description: Provide a detailed description of what you would like to see implemented or changed.
placeholder: |
The app could display lyrics in the screensaver. Additionally, a lyrics section could be integrated into the page that opens when music is played.
validations:
required: true
- type: textarea
attributes:
label: Alternatives considered
description: Outline any other approaches you have thought about or explored to solve the problem.
placeholder: |
An alternative approach could involve adding a dedicated "Lyrics" button in the app, similar to the Jellyfin web interface.
Users could press this button to open a full-screen lyrics view.
validations:
required: true
- type: textarea
attributes:
label: Additional information
description: Include any relevant details, resources, or screenshots that might help in understanding or implementing the request.
placeholder: Add any additional context here.
validations:
required: false
- type: input
id: other-sources
attributes:
label: Other sources
description: If this topic has been discussed outside of GitHub, please link it.
placeholder: https://forum.jellyfin.org/…
validations:
required: false

View File

@@ -1,15 +1,11 @@
<!--
Ensure your title is short, descriptive, and in the imperative mood (Fix X, Change Y, instead of Fixed X, Changed Y).
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our https://jellyfin.org/docs/general/contributing/issues/ page.
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our https://docs.jellyfin.org/general/contributing/issues.html page.
-->
**Changes**
<!-- Describe your changes here in 1-5 sentences. -->
**Code assistance**
<!-- If code assistance was used, describe how it contributed
e.g., code generated by LLM, explanation of code base, debugging guidance. -->
**Issues**
<!-- Tag any issues that this PR solves here.
ex. Fixes # -->

View File

@@ -4,7 +4,6 @@ on:
push:
branches:
- master
- release-*
pull_request:
permissions:
@@ -13,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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4.0.0
with:
distribution: temurin
java-version-file: .tools-versions
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4.0.0
with:
distribution: temurin
java-version-file: .tools-versions
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.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@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4.0.0
with:
distribution: temurin
java-version-file: .tools-versions
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.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@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4.0.0
with:
distribution: temurin
java-version-file: .tools-versions
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c # v5.0.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@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
- uses: eps1lon/actions-label-merge-conflict@fd1f295ee7443d13745804bc49fe158e240f6c6e # tag=v2.1.0
with:
dirtyLabel: merge conflict
repoToken: ${{ secrets.JF_BOT_TOKEN }}

View File

@@ -12,10 +12,10 @@ 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@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
days-before-stale: 120

1
.gitignore vendored
View File

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

View File

@@ -1 +0,0 @@
java 21

View File

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

View File

@@ -25,6 +25,9 @@
<a href="https://matrix.to/#/+jellyfin:matrix.org">
<img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfin:matrix.org.svg?logo=matrix"/>
</a>
<a href="https://www.reddit.com/r/jellyfin">
<img alt="Join our Subreddit" src="https://img.shields.io/badge/reddit-r%2Fjellyfin-%23FF5700.svg"/>
</a>
<br/>
<a href="https://play.google.com/store/apps/details?id=org.jellyfin.androidtv">
<img width="153" alt="Jellyfin on Google Play" src="https://jellyfin.org/images/store-icons/google-play.png"/>
@@ -39,39 +42,48 @@
<a href="https://repo.jellyfin.org/releases/client/androidtv/">Download archive</a>
</p>
Jellyfin Android TV is a Jellyfin client for Android TV, Nvidia Shield, and Amazon Fire TV devices. We welcome all contributions and pull
requests! If you have a larger feature in mind please open an issue so we can discuss the implementation before you start.
## Building
The app uses Gradle and requires the Android SDK. We recommend using Android Studio, which includes all required dependencies, for
development and building. For manual building without Android Studio make sure a compatible JDK and Android SDK are installed and in your
PATH, then use the Gradle wrapper (`./gradlew`) to build the project with the `assembleDebug` Gradle task to generate an apk file:
```shell
./gradlew assembleDebug
```
The task will create an APK file in the `/app/build/outputs/apk/debug` directory. This APK file uses a different app-id from our stable
builds and can be manually installed to your device.
## Branching
The `master` branch is the primary development branch and the target for all pull requests. It is **unstable** and may contain breaking
changes or unresolved bugs. For production deployments and forks, always use the latest `release-x.y.z` branch. Do not base production work
or long-lived forks on `master`.
Release branches are created at the start of a beta cycle and are kept up to date with each published release. Maintainers will cherry-pick
selected changes into release branches as needed for backports. These branches are reused for subsequent patch releases.
Jellyfin Android TV is a Jellyfin client for Android TV, Nvidia Shield, and Amazon Fire TV devices.
We welcome all contributions and pull requests! If you have a larger feature in mind please open an
issue so we can discuss the implementation before you start.
## Translating
Translations can be improved very easily from our [Weblate](https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv)
instance. Look through the following graphic to see if your native language could use some work! We cannot accept changes to translation
files via pull requests.
Translations can be improved very easily from our
[Weblate](https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv) instance.
Look through the following graphic to see if your native language could use some work!
<p align="center">
<a href="https://translate.jellyfin.org/engage/jellyfin-android/">
<img alt="Detailed Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin-android/-/jellyfin-androidtv/multi-auto.svg"/>
</a>
</p>
## Build Process
### Dependencies
- Android Studio
### Build
1. Clone or download this repository
```sh
git clone https://github.com/jellyfin/jellyfin-androidtv.git
cd jellyfin-androidtv
```
2. Open the project in Android Studio and run it from there or build an APK directly through Gradle:
```sh
./gradlew assembleDebug
```
Add the Android SDK to your PATH environment variable or create the ANDROID_SDK_ROOT variable for
this to work.
### Deploy to device/emulator
```sh
./gradlew installDebug
```
*You can also replace the "Debug" with "Release" to get an optimized release binary.*

View File

@@ -13,13 +13,12 @@
<issue id="MangledCRLF" severity="warning" />
<issue id="NegativeMargin" severity="warning" />
<issue id="NoHardKeywords" severity="warning" />
<issue id="NotificationPermission">
<ignore regexp="com.bumptech.glide.request.target.NotificationTarget" />
</issue>
<issue id="Registered" severity="warning" />
<issue id="RequiredSize" severity="error" />
<issue id="Typos" severity="ignore" />
<issue id="UnknownNullness" severity="informational" />
<issue id="UnusedIds" severity="ignore" />
<issue id="UnusedAttribute">
<ignore regexp="usesCleartextTraffic" />
<ignore regexp="networkSecurityConfig" />
</issue>
</lint>

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

@@ -1,38 +1,45 @@
plugins {
alias(libs.plugins.aboutlibraries)
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
id("com.android.application")
kotlin("android")
alias(libs.plugins.kotlin.serialization)
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
versionName = project.getVersionName()
versionCode = getVersionCode(versionName!!)
setProperty("archivesBaseName", "jellyfin-androidtv-v$versionName")
}
buildFeatures {
buildConfig = true
viewBinding = true
compose = true
resValues = true
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get()
}
buildTypes {
release {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
val release by getting {
isMinifyEnabled = false
// Set package names used in various XML files
resValue("string", "app_id", namespace!!)
@@ -45,7 +52,7 @@ android {
buildConfigField("boolean", "DEVELOPMENT", "false")
}
debug {
val debug by getting {
// Use different application id to run release and debug at the same time
applicationIdSuffix = ".debug"
@@ -73,10 +80,13 @@ android {
}
}
base.archivesName.set("jellyfin-androidtv-v${project.getVersionName()}")
aboutLibraries {
// Remove the "generated" timestamp to allow for reproducible builds
excludeFields = arrayOf("generated")
}
tasks.register("versionTxt") {
val path = layout.buildDirectory.asFile.get().resolve("version.txt")
val versionTxt by tasks.registering {
val path = buildDir.resolve("version.txt")
doLast {
val versionString = "v${android.defaultConfig.versionName}=${android.defaultConfig.versionCode}"
@@ -87,12 +97,12 @@ tasks.register("versionTxt") {
dependencies {
// Jellyfin
implementation(projects.design)
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) {
// Change version if desired
val sdkVersion = findProperty("sdk.version")?.toString()
@@ -110,12 +120,9 @@ dependencies {
// Android(x)
implementation(libs.androidx.core)
implementation(libs.androidx.activity)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.fragment)
implementation(libs.androidx.fragment.compose)
implementation(libs.androidx.leanback.core)
implementation(libs.androidx.leanback.preference)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.preference)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.tvprovider)
@@ -127,23 +134,22 @@ dependencies {
implementation(libs.androidx.cardview)
implementation(libs.androidx.startup)
implementation(libs.bundles.androidx.compose)
implementation(libs.accompanist.permissions)
// Dependency Injection
implementation(libs.bundles.koin)
// Media players
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.datasource.okhttp)
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.ui)
implementation(libs.jellyfin.androidx.media3.ffmpeg.decoder)
implementation(libs.libass.media3)
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

@@ -1 +0,0 @@
-dontwarn org.commonmark.ext.gfm.strikethrough.Strikethrough

View File

@@ -35,11 +35,6 @@
android:host="youtube.com"
android:scheme="https" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="video/*" />
</intent>
</queries>
<application
@@ -52,11 +47,22 @@
android:icon="@mipmap/app_icon"
android:label="@string/app_name"
android:largeHeap="true"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.Jellyfin"
android:usesCleartextTraffic="true">
<!-- Legacy service required for AccountManagerMigration -->
<service
android:name=".auth.service.AuthenticatorService"
android:exported="false">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
<!-- Screensaver -->
<service
android:name=".integration.dream.LibraryDreamService"
@@ -102,8 +108,9 @@
<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>
<action android:name="android.intent.action.MAIN" />
@@ -125,9 +132,17 @@
<!-- Main application -->
<activity
android:name=".ui.browsing.MainActivity"
android:launchMode="singleTask"
android:screenOrientation="landscape"
android:windowSoftInputMode="adjustNothing" />
<activity
android:name=".ui.preference.PreferencesActivity"
android:screenOrientation="behind"
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

@@ -5,7 +5,6 @@ import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.startup.AppInitializer
import androidx.startup.Initializer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.auth.repository.SessionRepository
import org.jellyfin.androidtv.di.KoinInitializer
@@ -18,7 +17,7 @@ class SessionInitializer : Initializer<Unit> {
.koin
// Restore system session
ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.IO) {
ProcessLifecycleOwner.get().lifecycleScope.launch {
koin.get<SessionRepository>().restoreSession(destroyOnly = false)
}
}

View File

@@ -0,0 +1,48 @@
package org.jellyfin.androidtv.auth
import android.accounts.AccountManager
import android.content.Context
import androidx.core.content.getSystemService
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
class AccountManagerMigration(
context: Context,
) {
private val accountManager = requireNotNull(context.getSystemService<AccountManager>())
fun migrate(
servers: Map<UUID, AuthenticationStoreServer>,
) = servers.mapValues { (serverId, server) ->
Timber.i("Migrating server $serverId (${server.name})")
server.copy(
users = server.users.mapValues { (userId, user) ->
val accessToken = getAccessToken(serverId, userId)
Timber.i("Migrating user $userId (${user.name}): ${if (accessToken != null) "success" else "no token"}")
user.copy(accessToken = accessToken)
}
)
}
@Suppress("MissingPermission")
private fun getAccessToken(serverId: UUID, userId: UUID): String? = runCatching {
accountManager.getAccountsByType(ACCOUNT_TYPE)
.firstOrNull {
val validServerId = accountManager.getUserData(it, ACCOUNT_DATA_SERVER)?.toUUIDOrNull() == serverId
val validUserId = accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == userId
validServerId && validUserId
}
?.let { account -> accountManager.peekAuthToken(account, ACCOUNT_ACCESS_TOKEN_TYPE) }
}.getOrNull()
companion object {
const val ACCOUNT_TYPE = BuildConfig.APPLICATION_ID
const val ACCOUNT_DATA_ID = "$ACCOUNT_TYPE.id"
const val ACCOUNT_DATA_SERVER = "$ACCOUNT_TYPE.server"
const val ACCOUNT_ACCESS_TOKEN_TYPE = "$ACCOUNT_TYPE.access_token"
}
}

View File

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

View File

@@ -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,9 +16,9 @@ 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),
) {
val serverVersion = version?.let(ServerVersion::fromString)
private val serverVersion = version?.let(ServerVersion::fromString)
val versionSupported = serverVersion != null && serverVersion >= ServerRepository.minimumServerVersion
operator fun compareTo(other: ServerVersion): Int = serverVersion?.compareTo(other) ?: -1

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

@@ -1,11 +1,9 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import org.jellyfin.androidtv.auth.model.ApiClientErrorLoginState
import org.jellyfin.androidtv.auth.model.AuthenticateMethod
import org.jellyfin.androidtv.auth.model.AuthenticatedState
@@ -23,10 +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.apiclient.JellyfinImage
import org.jellyfin.androidtv.util.apiclient.JellyfinImageSource
import org.jellyfin.androidtv.util.apiclient.getUrl
import org.jellyfin.androidtv.util.apiclient.primaryImage
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
@@ -34,13 +29,14 @@ import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.exception.TimeoutException
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.authenticateWithQuickConnect
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.api.AuthenticationResult
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.UserDto
import timber.log.Timber
import java.time.Instant
import java.util.Date
/**
* Repository to manage authentication of the user in the app.
@@ -60,6 +56,9 @@ class AuthenticationRepositoryImpl(
private val defaultDeviceInfo: DeviceInfo,
) : AuthenticationRepository {
override fun authenticate(server: Server, method: AuthenticateMethod): Flow<LoginState> {
// Check server version first
if (!server.versionSupported) return flowOf(ServerVersionNotSupported(server))
return when (method) {
is AutomaticAuthenticateMethod -> authenticateAutomatic(server, method.user)
is CredentialAuthenticateMethod -> authenticateCredential(server, method.username, method.password)
@@ -68,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)
@@ -76,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)
}
@@ -96,7 +97,7 @@ class AuthenticationRepositoryImpl(
}
emitAll(authenticateAuthenticationResult(server, result))
}.flowOn(Dispatchers.IO)
}
private fun authenticateQuickConnect(server: Server, secret: String) = flow {
val api = jellyfin.createApi(server.address, deviceInfo = defaultDeviceInfo)
@@ -114,7 +115,7 @@ class AuthenticationRepositoryImpl(
}
emitAll(authenticateAuthenticationResult(server, result))
}.flowOn(Dispatchers.IO)
}
private fun authenticateAuthenticationResult(server: Server, result: AuthenticationResult) = flow {
val accessToken = result.accessToken ?: return@flow emit(RequireSignInState)
@@ -124,8 +125,9 @@ class AuthenticationRepositoryImpl(
serverId = server.id,
name = userInfo.name!!,
accessToken = result.accessToken,
imageTag = userInfo.primaryImage?.tag,
lastUsed = Instant.now().toEpochMilli(),
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag,
lastUsed = Date().time,
)
authenticateFinish(server, userInfo, accessToken)
@@ -134,18 +136,16 @@ class AuthenticationRepositoryImpl(
emit(AuthenticatedState)
} else {
Timber.w("Failed to set active session after authenticating")
if (!server.versionSupported) emit(ServerVersionNotSupported(server))
else emit(RequireSignInState)
emit(RequireSignInState)
}
}.flowOn(Dispatchers.IO)
}
private fun authenticateToken(server: Server, user: User) = flow {
emit(AuthenticatingState)
val success = setActiveSession(user, server)
if (!success) {
if (!server.versionSupported) emit(ServerVersionNotSupported(server))
else emit(RequireSignInState)
emit(RequireSignInState)
} else try {
// Update user info
val userInfo by userApiClient.userApi.getCurrentUser()
@@ -159,19 +159,21 @@ class AuthenticationRepositoryImpl(
Timber.e(err, "Unable to get current user data")
emit(ApiClientErrorLoginState(err))
}
}.flowOn(Dispatchers.IO)
}
private suspend fun authenticateFinish(server: Server, userInfo: UserDto, accessToken: String) {
val currentUser = authenticationStore.getUser(server.id, userInfo.id)
val updatedUser = currentUser?.copy(
name = userInfo.name!!,
lastUsed = Instant.now().toEpochMilli(),
imageTag = userInfo.primaryImage?.tag,
lastUsed = Date().time,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag,
accessToken = accessToken,
) ?: AuthenticationStoreUser(
name = userInfo.name!!,
imageTag = userInfo.primaryImage?.tag,
requirePassword = userInfo.hasPassword,
imageTag = userInfo.primaryImageTag,
accessToken = accessToken,
)
authenticationStore.putUser(server.id, userInfo.id, updatedUser)
@@ -183,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,15 +205,12 @@ class AuthenticationRepositoryImpl(
else false
}
override fun getUserImageUrl(server: Server, user: User): String? = user.imageTag?.let { primaryImageTag ->
JellyfinImage(
item = user.id,
source = JellyfinImageSource.USER,
type = ImageType.PRIMARY,
tag = primaryImageTag,
blurHash = null,
aspectRatio = null,
index = null
override fun getUserImageUrl(server: Server, user: User): String? = user.imageTag?.let { tag ->
jellyfin.createApi(server.address).imageApi.getUserImageUrl(
userId = user.id,
tag = tag,
imageType = ImageType.PRIMARY,
maxHeight = ImageUtils.MAX_PRIMARY_IMAGE_HEIGHT
)
}?.getUrl(jellyfin.createApi(server.address))
}
}

View File

@@ -1,14 +1,11 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
import org.jellyfin.androidtv.auth.model.ConnectedState
import org.jellyfin.androidtv.auth.model.ConnectingState
@@ -26,11 +23,11 @@ import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.discovery.RecommendedServerInfo
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.model.ServerVersion
import org.jellyfin.sdk.model.api.BrandingOptionsDto
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
/**
@@ -39,23 +36,20 @@ import java.util.UUID
interface ServerRepository {
val storedServers: StateFlow<List<Server>>
val discoveredServers: StateFlow<List<Server>>
val currentServer: StateFlow<Server?>
suspend fun loadStoredServers()
suspend fun loadDiscoveryServers()
fun setCurrentServer(server: Server?)
fun addServer(address: String): Flow<ServerAdditionState>
suspend fun getServer(id: UUID, eagerUpdate: Boolean = false): Server?
suspend fun updateServer(server: Server, force: Boolean = false): Boolean
suspend fun getServer(id: UUID): Server?
suspend fun updateServer(server: Server): Boolean
suspend fun deleteServer(server: UUID): Boolean
companion object {
val minimumServerVersion = Jellyfin.minimumVersion.copy(build = null)
val recommendedServerVersion = Jellyfin.apiVersion.copy(build = null)
val upcomingMinimumServerVersion = ServerVersion(10, 11, 0)
val upcomingMinimumServerVersion = ServerVersion(10, 9, 0)
}
}
@@ -70,9 +64,6 @@ class ServerRepositoryImpl(
private val _discoveredServers = MutableStateFlow(emptyList<Server>())
override val discoveredServers = _discoveredServers.asStateFlow()
private val _currentServer = MutableStateFlow<Server?>(null)
override val currentServer = _currentServer.asStateFlow()
// Loading data
override suspend fun loadStoredServers() {
authenticationStore.getServers()
@@ -93,10 +84,6 @@ class ServerRepositoryImpl(
}
}
override fun setCurrentServer(server: Server?) {
_currentServer.value = server
}
// Mutating data
override fun addServer(address: String): Flow<ServerAdditionState> = flow {
Timber.i("Adding server %s", address)
@@ -108,14 +95,13 @@ 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 -> {
goodRecommendations += recommendedServer
false
}
else -> {
badRecommendations += recommendedServer
false
@@ -123,9 +109,9 @@ class ServerRepositoryImpl(
}
}
Timber.i(buildString {
Timber.d(buildString {
append("Recommendations: ")
if (greatRecommendation == null) append(0)
if (greatRecommendaton == null) append(0)
else append(1)
append(" great, ")
append(goodRecommendations.size)
@@ -134,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()
@@ -152,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,
@@ -173,17 +159,13 @@ class ServerRepositoryImpl(
.mapValues { (_, entry) -> entry.flatMap { server -> server.issues } }
emit(UnableToConnectState(addressCandidatesWithIssues))
}
}.flowOn(Dispatchers.IO)
}
override suspend fun getServer(id: UUID, eagerUpdate: Boolean): Server? {
override suspend fun getServer(id: UUID): Server? {
val server = authenticationStore.getServer(id) ?: return null
val updatedServer = try {
val forceUpdate = eagerUpdate && server.version
?.let(ServerVersion::fromString)
?.let { version -> version < ServerRepository.minimumServerVersion } == true
updateServerInternal(id, server, forceUpdate)
updateServerInternal(id, server)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to update server")
null
@@ -192,12 +174,12 @@ class ServerRepositoryImpl(
return (updatedServer ?: server).asServer(id)
}
override suspend fun updateServer(server: Server, force: Boolean): Boolean {
override suspend fun updateServer(server: Server): Boolean {
// Only update existing servers
val serverInfo = authenticationStore.getServer(server.id) ?: return false
return try {
updateServerInternal(server.id, serverInfo, force) != null
updateServerInternal(server.id, serverInfo) != null
} catch (err: ApiClientException) {
Timber.e(err, "Unable to update server")
@@ -205,32 +187,25 @@ class ServerRepositoryImpl(
}
}
private suspend fun updateServerInternal(
id: UUID,
server: AuthenticationStoreServer,
forceUpdate: Boolean
): AuthenticationStoreServer? {
val now = Instant.now().toEpochMilli()
private suspend fun updateServerInternal(id: UUID, server: AuthenticationStoreServer): AuthenticationStoreServer? {
val now = Date().time
// Only update every 10 minutes
if (now - server.lastRefreshed < 600000 && server.version != null && !forceUpdate) return null
if (now - server.lastRefreshed < 600000 && server.version != null) return null
val newServer = withContext(Dispatchers.IO) {
val api = jellyfin.createApi(server.address)
val api = jellyfin.createApi(server.address)
// Get login disclaimer
val branding = api.getBrandingOptionsOrDefault()
val systemInfo by api.systemApi.getPublicSystemInfo()
// Get login disclaimer
val branding = api.getBrandingOptionsOrDefault()
val systemInfo by api.systemApi.getPublicSystemInfo()
server.copy(
name = systemInfo.serverName ?: server.name,
version = systemInfo.version ?: server.version,
loginDisclaimer = branding.loginDisclaimer ?: server.loginDisclaimer,
splashscreenEnabled = branding.splashscreenEnabled,
setupCompleted = systemInfo.startupWizardCompleted ?: server.setupCompleted,
lastRefreshed = now
)
}
val newServer = server.copy(
name = systemInfo.serverName ?: server.name,
version = systemInfo.version ?: server.version,
loginDisclaimer = branding.loginDisclaimer ?: server.loginDisclaimer,
splashscreenEnabled = branding.splashscreenEnabled,
setupCompleted = systemInfo.startupWizardCompleted ?: server.setupCompleted,
lastRefreshed = now
)
authenticationStore.putServer(id, newServer)
return newServer
@@ -251,7 +226,7 @@ class ServerRepositoryImpl(
loginDisclaimer = loginDisclaimer,
splashscreenEnabled = splashscreenEnabled,
setupCompleted = setupCompleted,
dateLastAccessed = Instant.ofEpochMilli(lastUsed),
dateLastAccessed = Date(lastUsed),
)
/**
@@ -262,7 +237,7 @@ class ServerRepositoryImpl(
brandingApi.getBrandingOptions().content
} catch (exception: InvalidContentException) {
Timber.w(exception, "Invalid branding options response, using default value")
BrandingOptionsDto(
BrandingOptions(
loginDisclaimer = null,
customCss = null,
splashscreenEnabled = false,

View File

@@ -1,7 +1,5 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.auth.model.PrivateUser
import org.jellyfin.androidtv.auth.model.PublicUser
import org.jellyfin.androidtv.auth.model.Server
@@ -37,6 +35,7 @@ class ServerUserRepositoryImpl(
serverId = server.id,
name = userInfo.name,
accessToken = authInfo?.accessToken,
requirePassword = userInfo.requirePassword,
imageTag = userInfo.imageTag,
lastUsed = userInfo.lastUsed,
)
@@ -49,9 +48,7 @@ class ServerUserRepositoryImpl(
val api = jellyfin.createApi(server.address)
return try {
val users = withContext(Dispatchers.IO) {
api.userApi.getPublicUsers().content
}
val users by api.userApi.getPublicUsers()
users.mapNotNull(UserDto::toPublicUser)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to retrieve public users")

View File

@@ -1,6 +1,5 @@
package org.jellyfin.androidtv.auth.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -8,7 +7,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.auth.apiclient.ApiBinder
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.jellyfin.androidtv.preference.PreferencesRepository
@@ -49,6 +48,7 @@ interface SessionRepository {
class SessionRepositoryImpl(
private val authenticationPreferences: AuthenticationPreferences,
private val apiBinder: ApiBinder,
private val authenticationStore: AuthenticationStore,
private val userApiClient: ApiClient,
private val preferencesRepository: PreferencesRepository,
@@ -112,15 +112,13 @@ class SessionRepositoryImpl(
override fun destroyCurrentSession() {
Timber.i("Destroying current session")
userRepository.setCurrentUser(null)
serverRepository.setCurrentServer(null)
userRepository.updateCurrentUser(null)
_currentSession.value = null
apiBinder.updateSession(null, userApiClient.deviceInfo)
_state.value = SessionRepositoryState.READY
}
private suspend fun setCurrentSession(session: Session?): Boolean {
var server: Server? = null
if (session != null) {
// No change in session - don't switch
if (currentSession.value?.userId == session.userId) return true
@@ -130,40 +128,40 @@ class SessionRepositoryImpl(
authenticationPreferences[AuthenticationPreferences.lastUserId] = session.userId.toString()
// Check if server version is supported
server = serverRepository.getServer(session.serverId, true)
val server = serverRepository.getServer(session.serverId)
if (server == null || !server.versionSupported) return false
}
// Update session after binding the apiclient settings
val deviceInfo = session?.let { defaultDeviceInfo.forUser(it.userId) } ?: defaultDeviceInfo
Timber.i("Updating current session. userId=${session?.userId} server=${server?.serverVersion}")
val success = apiBinder.updateSession(session, deviceInfo)
Timber.i("Updating current session. userId=${session?.userId} apiBindingSuccess=${success}")
val applied = userApiClient.applySession(session, deviceInfo)
if (applied && session != null) {
try {
val user = withContext(Dispatchers.IO) {
userApiClient.userApi.getCurrentUser().content
if (success) {
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)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to authenticate: bad response when getting user info")
destroyCurrentSession()
return false
}
userRepository.setCurrentUser(user)
serverRepository.setCurrentServer(server)
} catch (err: ApiClientException) {
Timber.e(err, "Unable to authenticate: bad response when getting user info")
destroyCurrentSession()
return false
} else {
userRepository.updateCurrentUser(null)
}
preferencesRepository.onSessionChanged()
_currentSession.value = session
} else destroyCurrentSession()
// Update crash reporting URL
val crashReportUrl = userApiClient.clientLogApi.logFileUrl()
telemetryPreferences[TelemetryPreferences.crashReportUrl] = crashReportUrl
telemetryPreferences[TelemetryPreferences.crashReportToken] = session.accessToken
} else {
userRepository.setCurrentUser(null)
serverRepository.setCurrentServer(null)
}
preferencesRepository.onSessionChanged()
_currentSession.value = session
return true
return success
}
private fun createLastUserSession(): Session? {
@@ -186,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

@@ -10,13 +10,13 @@ import org.jellyfin.sdk.model.api.UserDto
interface UserRepository {
val currentUser: StateFlow<UserDto?>
fun setCurrentUser(user: UserDto?)
fun updateCurrentUser(user: UserDto?)
}
class UserRepositoryImpl : UserRepository {
override val currentUser = MutableStateFlow<UserDto?>(null)
override fun setCurrentUser(user: UserDto?) {
currentUser.value = user
override fun updateCurrentUser(user: UserDto?) {
if (currentUser.value !== user) currentUser.value = user
}
}

View File

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

View File

@@ -2,6 +2,7 @@ package org.jellyfin.androidtv.auth.store
import android.content.Context
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -12,6 +13,7 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.contextual
import org.jellyfin.androidtv.auth.AccountManagerMigration
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
import org.jellyfin.androidtv.auth.model.AuthenticationStoreUser
import org.jellyfin.sdk.model.serializer.UUIDSerializer
@@ -26,6 +28,7 @@ import java.util.UUID
*/
class AuthenticationStore(
private val context: Context,
private val accountManagerMigration: AccountManagerMigration,
) {
private val storePath
get() = context.filesDir.resolve("authentication_store.json")
@@ -56,13 +59,11 @@ class AuthenticationStore(
// Check for version
return when (root["version"]?.jsonPrimitive?.intOrNull) {
// Migration was removed, clear stored servers
1 -> {
Timber.e("Migrating from version 1 is no longer possible")
emptyMap()
}
1 -> json.decodeFromJsonElement<Map<UUID, AuthenticationStoreServer>>(root["servers"]!!)
// Add access tokens from account manager to stored users and save the migrated data
.let { servers -> accountManagerMigration.migrate(servers) }
.also { servers -> write(servers) }
// Current version, return as-is
2 -> json.decodeFromJsonElement<Map<UUID, AuthenticationStoreServer>>(root["servers"]!!)
null -> {

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"
@@ -66,7 +63,6 @@ object Codec {
const val VP8 = "vp8"
const val VP9 = "vp9"
const val AV1 = "av1"
const val VC1 = "vc1"
}
object Subtitle {

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,8 @@ enum class QueryType {
LiveTvProgram,
LiveTvRecording,
StaticItems,
Persons,
StaticAudioQueueItems,
Artists,
AlbumArtists,
AudioPlaylists,

View File

@@ -1,13 +1,13 @@
package org.jellyfin.androidtv.data.compat
import org.jellyfin.sdk.model.api.DeviceProfile
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
@@ -21,4 +21,11 @@ open class AudioOptions {
* Example: DeviceProfile supports five channel, but user only has stereo speakers
*/
var maxAudioChannels: Int? = null
/**
* The application's configured quality setting
*/
var maxBitrate: Int? = null
var context = EncodingContext.Streaming
}

View File

@@ -1,7 +1,7 @@
package org.jellyfin.androidtv.data.compat
import org.jellyfin.sdk.model.api.PlaybackErrorCode
import org.jellyfin.apiclient.model.dlna.PlaybackErrorCode
class PlaybackException : RuntimeException() {
var errorCode = PlaybackErrorCode.NOT_ALLOWED
var errorCode = PlaybackErrorCode.NotAllowed
}

View File

@@ -0,0 +1,97 @@
package org.jellyfin.androidtv.data.compat;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod;
import org.jellyfin.apiclient.model.dlna.SubtitleProfile;
import org.jellyfin.apiclient.model.entities.MediaStream;
import org.jellyfin.apiclient.model.session.PlayMethod;
public class StreamBuilder
{
public static SubtitleProfile getSubtitleProfile(org.jellyfin.sdk.model.api.MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod)
{
if (playMethod != PlayMethod.Transcode && !subtitleStream.isExternal())
{
// Look for supported embedded subs
for (SubtitleProfile profile : subtitleProfiles)
{
if (!profile.SupportsLanguage(subtitleStream.getLanguage()))
{
continue;
}
if (profile.getMethod() != SubtitleDeliveryMethod.Embed)
{
continue;
}
if (subtitleStream.isTextSubtitleStream() == MediaStream.IsTextFormat(profile.getFormat()) && Utils.equalsIgnoreCase(profile.getFormat(), subtitleStream.getCodec()))
{
return profile;
}
}
}
// Look for an external or hls profile that matches the stream type (text/graphical) and doesn't require conversion
SubtitleProfile tempVar = new SubtitleProfile();
tempVar.setMethod(SubtitleDeliveryMethod.Encode);
tempVar.setFormat(subtitleStream.getCodec());
SubtitleProfile tempVar2 = getExternalSubtitleProfile(subtitleStream, subtitleProfiles, playMethod, false);
SubtitleProfile tempVar3 = getExternalSubtitleProfile(subtitleStream, subtitleProfiles, playMethod, true);
return (tempVar2 != null) ? tempVar2 : (tempVar3 != null) ? tempVar3 : tempVar;
}
private static SubtitleProfile getExternalSubtitleProfile(org.jellyfin.sdk.model.api.MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod, boolean allowConversion)
{
for (SubtitleProfile profile : subtitleProfiles)
{
if (profile.getMethod() != SubtitleDeliveryMethod.External && profile.getMethod() != SubtitleDeliveryMethod.Hls)
{
continue;
}
if (profile.getMethod() == SubtitleDeliveryMethod.Hls && playMethod != PlayMethod.Transcode)
{
continue;
}
if (!profile.SupportsLanguage(subtitleStream.getLanguage()))
{
continue;
}
if ((profile.getMethod() == SubtitleDeliveryMethod.External && subtitleStream.isTextSubtitleStream() == MediaStream.IsTextFormat(profile.getFormat())) || (profile.getMethod() == SubtitleDeliveryMethod.Hls && subtitleStream.isTextSubtitleStream()))
{
boolean requiresConversion = !Utils.equalsIgnoreCase(subtitleStream.getCodec(), profile.getFormat());
if (!requiresConversion)
{
return profile;
}
if (!allowConversion)
{
continue;
}
if (subtitleStream.isTextSubtitleStream() && subtitleStream.getSupportsExternalStream() && supportsSubtitleConversionTo(subtitleStream, profile.getFormat()))
{
return profile;
}
}
}
return null;
}
public static boolean supportsSubtitleConversionTo(org.jellyfin.sdk.model.api.MediaStream mediaStream, String codec)
{
if (!mediaStream.isTextSubtitleStream())
{
return false;
}
// Can't convert from this
return !("ass".equalsIgnoreCase(mediaStream.getCodec()) || "ssa".equalsIgnoreCase(mediaStream.getCodec()) || "ass".equalsIgnoreCase(codec) || "ssa".equalsIgnoreCase(codec));
}
}

View File

@@ -1,22 +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.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.PlayMethod;
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod;
import java.util.ArrayList;
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;
}
@@ -30,14 +45,34 @@ public class StreamInfo {
MediaUrl = value;
}
private PlayMethod playMethod = PlayMethod.DIRECT_PLAY;
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];
public final EncodingContext getContext() {
return Context;
}
public final void setContext(EncodingContext value) {
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;
@@ -50,6 +85,46 @@ 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() {
return DeviceProfile;
}
public final void setDeviceProfile(DeviceProfile value) {
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() {
@@ -60,6 +135,12 @@ public class StreamInfo {
RunTimeTicks = value;
}
private TranscodeSeekInfo TranscodeSeekInfo = getTranscodeSeekInfo().values()[0];
public final TranscodeSeekInfo getTranscodeSeekInfo() {
return TranscodeSeekInfo;
}
private MediaSourceInfo MediaSource;
public final MediaSourceInfo getMediaSource() {
@@ -70,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;
@@ -90,6 +171,185 @@ public class StreamInfo {
return getMediaSource() == null ? null : getMediaSource().getId();
}
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>();
// HLS will preserve timestamps so we can just grab the full subtitle stream
long startPositionTicks = getPlayMethod() == PlayMethod.Transcode ? getStartPositionTicks() : 0;
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 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;
}
public final ArrayList<MediaStream> getSelectableAudioStreams() {
return getSelectableStreams(MediaStreamType.AUDIO);
}

View File

@@ -0,0 +1,12 @@
package org.jellyfin.androidtv.data.compat
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod
class SubtitleStreamInfo {
var url: String? = null
var name: String? = null
var format: String? = null
var displayTitle: String? = null
var index = 0
var deliveryMethod = SubtitleDeliveryMethod.Encode
}

View File

@@ -2,160 +2,114 @@ package org.jellyfin.androidtv.data.eventhandling
import android.content.Context
import android.media.AudioManager
import android.os.Build
import android.widget.Toast
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import androidx.lifecycle.repeatOnLifecycle
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 lifecycle: Lifecycle,
) {
init {
lifecycle.coroutineScope.launch(Dispatchers.IO) {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
subscribe(this)
}
}
}
private val coroutineScope = CoroutineScope(Dispatchers.IO)
val state = socketInstance.state
suspend fun updateSession() {
try {
withContext(Dispatchers.IO) {
api.sessionApi.postCapabilities(
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)
api.sessionApi.postCapabilities(
playableMediaTypes = listOf(MediaType.Video, MediaType.Audio),
supportsMediaControl = true,
supportedCommands = buildList {
add(GeneralCommandType.DISPLAY_CONTENT)
add(GeneralCommandType.DISPLAY_MESSAGE)
add(GeneralCommandType.SEND_STRING)
add(GeneralCommandType.DISPLAY_MESSAGE)
add(GeneralCommandType.SEND_STRING)
// Note: These are used in the PlaySessionSocketService
if (!audioManager.isVolumeFixed) {
add(GeneralCommandType.VOLUME_UP)
add(GeneralCommandType.VOLUME_DOWN)
add(GeneralCommandType.SET_VOLUME)
// Note: These are used in the PlaySessionSocketService
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !audioManager.isVolumeFixed) {
add(GeneralCommandType.VOLUME_UP)
add(GeneralCommandType.VOLUME_DOWN)
add(GeneralCommandType.SET_VOLUME)
add(GeneralCommandType.MUTE)
add(GeneralCommandType.UNMUTE)
add(GeneralCommandType.TOGGLE_MUTE)
}
},
)
}
add(GeneralCommandType.MUTE)
add(GeneralCommandType.UNMUTE)
add(GeneralCommandType.TOGGLE_MUTE)
}
},
)
} catch (err: ApiClientException) {
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()
}
private fun subscribe(coroutineScope: CoroutineScope) = api.webSocket.apply {
// Library
subscribe<LibraryChangedMessage>()
.onEach { message -> message.data?.let(::onLibraryChanged) }
.launchIn(coroutineScope)
init {
socketInstance.apply {
// Library
addListener<LibraryChangedMessage> { message -> onLibraryChanged(message.info) }
// Media playback
subscribe<PlayMessage>()
.onEach { message -> onPlayMessage(message) }
.launchIn(coroutineScope)
// Media playback
addListener<PlayMessage> { message -> onPlayMessage(message) }
addListener<PlayStateMessage> { message -> onPlayStateMessage(message) }
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)
// General commands
subscribeGeneralCommand(GeneralCommandType.DISPLAY_CONTENT)
.onEach { message ->
// General commands
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 itemKind = itemType?.let { type -> BaseItemKind.values().find { value -> value.serialName.equals(type, true) } }
if (itemUuid != null && itemKind != null) onDisplayContent(itemUuid, itemKind)
}
.launchIn(coroutineScope)
subscribeGeneralCommands(setOf(GeneralCommandType.DISPLAY_MESSAGE, GeneralCommandType.SEND_STRING))
.onEach { message ->
addGeneralCommandsListener(setOf(GeneralCommandType.DISPLAY_MESSAGE, GeneralCommandType.SEND_STRING)) { message ->
val header by message
val text by message
val string by message
onDisplayMessage(header, text ?: string)
}
.launchIn(coroutineScope)
}
}
private fun onLibraryChanged(info: LibraryUpdateInfo) {
@@ -167,70 +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 itemIds = message.data?.itemIds ?: return
val itemId = message.request.itemIds?.firstOrNull() ?: return
runCatching {
playbackHelper.retrieveAndPlay(
itemIds,
PlaybackHelper.retrieveAndPlay(
itemId.toString(),
false,
message.data?.startPositionTicks,
message.data?.startIndex,
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,11 +1,10 @@
package org.jellyfin.androidtv.data.model
import org.jellyfin.androidtv.util.apiclient.JellyfinImage
import java.util.UUID
data class ChapterItemInfo(
val itemId: UUID,
val name: String?,
val startPositionTicks: Long,
val image: JellyfinImage?,
val imagePath: String?,
)

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

@@ -1,62 +0,0 @@
package org.jellyfin.androidtv.data.repository
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.ResolveInfo
import androidx.core.net.toUri
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.util.componentName
class ExternalAppRepository(
private val userPreferences: UserPreferences,
) {
companion object {
const val SAMPLE_VIDEO_URL = "http://jellyfin.local/query.mp4"
const val MEDIA_TYPE_VIDEO = "video/*"
}
private val externalPlayerAppIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndTypeAndNormalize(SAMPLE_VIDEO_URL.toUri(), MEDIA_TYPE_VIDEO)
}
fun getExternalPlayerApps(context: Context): List<ResolveInfo> = context.packageManager
.queryIntentActivities(externalPlayerAppIntent, 0)
// Hide apps with priority below zero (system stubs)
.filter { it.priority >= 0 }
fun getCurrentExternalPlayerApp(context: Context): ActivityInfo? {
// Validate if external app should be used at all
val useExternalPlayer = userPreferences[UserPreferences.useExternalPlayer]
if (!useExternalPlayer) return null
// Resolve external app information
val resolvedInfo = userPreferences[UserPreferences.externalPlayerComponentName]
.takeIf { it.isNotEmpty() }
?.let(ComponentName::unflattenFromString)
?.runCatching { context.packageManager.getActivityInfo(this, 0) }
?.getOrNull()
if (resolvedInfo != null) return resolvedInfo
// Fallback in case the app is uninstalled or unavailable for some other reason
val externalApps = getExternalPlayerApps(context)
// System default
val systemDefault = externalApps.find { it.isDefault }?.activityInfo
if (systemDefault != null) return systemDefault
// First compatible, or none
return externalApps.firstOrNull()?.activityInfo
}
fun setExternalPlayerapp(activityInfo: ActivityInfo?) {
if (activityInfo == null) {
userPreferences[UserPreferences.useExternalPlayer] = false
userPreferences[UserPreferences.externalPlayerComponentName] = ""
} else {
userPreferences[UserPreferences.useExternalPlayer] = true
userPreferences[UserPreferences.externalPlayerComponentName] = activityInfo.componentName.flattenToShortString()
}
}
}

View File

@@ -1,14 +1,11 @@
package org.jellyfin.androidtv.data.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.data.model.DataRefreshService
import org.jellyfin.sdk.api.client.ApiClient
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
@@ -21,18 +18,18 @@ class ItemMutationRepositoryImpl(
) : ItemMutationRepository {
override suspend fun setFavorite(item: UUID, favorite: Boolean): UserItemDataDto {
val response by when {
favorite -> withContext(Dispatchers.IO) { api.userLibraryApi.markFavoriteItem(itemId = item) }
else -> withContext(Dispatchers.IO) { api.userLibraryApi.unmarkFavoriteItem(itemId = item) }
favorite -> api.userLibraryApi.markFavoriteItem(itemId = item)
else -> api.userLibraryApi.unmarkFavoriteItem(itemId = item)
}
dataRefreshService.lastFavoriteUpdate = Instant.now()
dataRefreshService.lastFavoriteUpdate = System.currentTimeMillis()
return response
}
override suspend fun setPlayed(item: UUID, played: Boolean): UserItemDataDto {
val response by when {
played -> withContext(Dispatchers.IO) { api.playStateApi.markPlayedItem(itemId = item) }
else -> withContext(Dispatchers.IO) { api.playStateApi.markUnplayedItem(itemId = item) }
played -> api.playStateApi.markPlayedItem(itemId = item)
else -> api.playStateApi.markUnplayedItem(itemId = item)
}
return response

View File

@@ -1,25 +0,0 @@
package org.jellyfin.androidtv.data.repository
import org.jellyfin.sdk.model.api.ItemFields
object ItemRepository {
val itemFields = setOf(
ItemFields.CAN_DELETE,
ItemFields.CHANNEL_INFO,
ItemFields.CHAPTERS,
ItemFields.CHILD_COUNT,
ItemFields.CUMULATIVE_RUN_TIME_TICKS,
ItemFields.DATE_CREATED,
ItemFields.DISPLAY_PREFERENCES_ID,
ItemFields.GENRES,
ItemFields.ITEM_COUNTS,
ItemFields.MEDIA_SOURCE_COUNT,
ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_STREAMS,
ItemFields.OVERVIEW,
ItemFields.PATH,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.TAGLINES,
ItemFields.TRICKPLAY,
)
}

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

@@ -1,20 +1,18 @@
package org.jellyfin.androidtv.data.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
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(
@@ -23,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)
}.flowOn(Dispatchers.IO)
}
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
@@ -15,15 +15,12 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.auth.model.Server
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.preference.constant.BackdropBehavior
import org.jellyfin.androidtv.util.apiclient.getUrl
import org.jellyfin.androidtv.util.apiclient.itemBackdropImages
import org.jellyfin.androidtv.util.apiclient.parentBackdropImages
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.BaseItemDto
import java.time.Instant
import org.jellyfin.sdk.model.api.ImageType
import java.util.UUID
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@@ -50,29 +47,39 @@ 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]
private fun List<String>?.getUrls(itemId: UUID?): List<String> {
// Check for nullability
if (itemId == null || isNullOrEmpty()) return emptyList()
return mapIndexed { index, tag ->
api.imageApi.getItemImageUrl(
itemId = itemId,
imageType = ImageType.BACKDROP,
tag = tag,
imageIndex = index,
fillWidth = context.resources.displayMetrics.widthPixels,
fillHeight = context.resources.displayMetrics.heightPixels,
)
}
}
/**
* Use all available backdrops from [baseItem] as background.
*/
fun setBackground(baseItem: BaseItemDto?) {
val backdropBehavior = userPreferences[UserPreferences.backdropBehavior]
// Check if item is set and backgrounds are enabled
if (baseItem == null || backdropBehavior == BackdropBehavior.DISABLED)
if (baseItem == null || !userPreferences[UserPreferences.backdropEnabled])
return clearBackgrounds()
// Enable blur for backdrops
_blurBackground.value = backdropBehavior == BackdropBehavior.BACKDROP_WITH_BLUR
// Get all backdrop urls
val backdropUrls = (baseItem.itemBackdropImages + baseItem.parentBackdropImages)
.map { it.getUrl(api) }
.toSet()
val itemBackdropUrls = baseItem.backdropImageTags.getUrls(baseItem.id)
val parentBackdropUrls = baseItem.parentBackdropImageTags.getUrls(baseItem.parentBackdropItemId)
val backdropUrls = itemBackdropUrls.union(parentBackdropUrls)
loadBackgrounds(backdropUrls)
}
@@ -82,16 +89,13 @@ class BackgroundService(
*/
fun setBackground(server: Server) {
// Check if item is set and backgrounds are enabled
if (userPreferences[UserPreferences.backdropBehavior] == BackdropBehavior.DISABLED)
if (!userPreferences[UserPreferences.backdropEnabled])
return clearBackgrounds()
// Check if splashscreen is enabled in (cached) branding options
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()
@@ -111,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
@@ -140,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,16 +2,10 @@ package org.jellyfin.androidtv.di
import android.content.Context
import android.os.Build
import androidx.lifecycle.ProcessLifecycleOwner
import coil3.ImageLoader
import coil3.annotation.ExperimentalCoilApi
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.NetworkFetcher
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.serviceLoaderEnabled
import coil3.svg.SvgDecoder
import coil3.util.Logger
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
@@ -20,7 +14,6 @@ import org.jellyfin.androidtv.data.eventhandling.SocketHandler
import org.jellyfin.androidtv.data.model.DataRefreshService
import org.jellyfin.androidtv.data.repository.CustomMessageRepository
import org.jellyfin.androidtv.data.repository.CustomMessageRepositoryImpl
import org.jellyfin.androidtv.data.repository.ExternalAppRepository
import org.jellyfin.androidtv.data.repository.ItemMutationRepository
import org.jellyfin.androidtv.data.repository.ItemMutationRepositoryImpl
import org.jellyfin.androidtv.data.repository.NotificationsRepository
@@ -28,100 +21,85 @@ 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.InteractionTrackerViewModel
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
import org.jellyfin.androidtv.ui.ScreensaverViewModel
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.playback.stillwatching.StillWatchingViewModel
import org.jellyfin.androidtv.ui.player.photo.PhotoPlayerViewModel
import org.jellyfin.androidtv.ui.search.SearchFragmentDelegate
import org.jellyfin.androidtv.ui.search.SearchRepository
import org.jellyfin.androidtv.ui.search.SearchRepositoryImpl
import org.jellyfin.androidtv.ui.search.SearchViewModel
import org.jellyfin.androidtv.ui.settings.compat.SettingsViewModel
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.coil.CoilTimberLogger
import org.jellyfin.androidtv.util.coil.createCoilConnectivityChecker
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.HttpClientOptions
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
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
import org.jellyfin.sdk.Jellyfin as JellyfinSdk
val defaultDeviceInfo = named("defaultDeviceInfo")
val appModule = module {
// SDK
// New SDK
single(defaultDeviceInfo) { androidDevice(get()) }
single { OkHttpFactory() }
single { HttpClientOptions() }
single {
createJellyfin {
context = androidContext()
// Add client info
val clientName = buildString {
append("Jellyfin Android TV")
if (BuildConfig.DEBUG) append(" (debug)")
}
clientInfo = ClientInfo(clientName, BuildConfig.VERSION_NAME)
clientInfo = ClientInfo("Android TV", BuildConfig.VERSION_NAME)
deviceInfo = get(defaultDeviceInfo)
// Change server version
minimumServerVersion = ServerRepository.minimumServerVersion
// Use our own shared factory instance
apiClientFactory = get<OkHttpFactory>()
socketConnectionFactory = get<OkHttpFactory>()
}
}
single {
// Create an empty API instance, the actual values are set by the SessionRepository
get<JellyfinSdk>().createApi(httpClientOptions = get<HttpClientOptions>())
get<JellyfinSdk>().createApi()
}
single { SocketHandler(get(), get(), get(), get(), get(), get(), get(), get(), get(), ProcessLifecycleOwner.get().lifecycle) }
single { get<ApiClient>().ws() }
// Coil (images)
single { SocketHandler(get(), get(), get(), get(), get(), get(), get(), get()) }
// Old apiclient
single {
val okHttpFactory = get<OkHttpFactory>()
val httpClientOptions = get<HttpClientOptions>()
JellyfinApiClient {
appInfo = AppInfo("Android TV", BuildConfig.VERSION_NAME)
logger = AndroidLogger()
android(androidApplication())
}
}
@OptIn(ExperimentalCoilApi::class)
OkHttpNetworkFetcherFactory(
callFactory = { okHttpFactory.createClient(httpClientOptions) },
connectivityChecker = ::createCoilConnectivityChecker,
single {
get<JellyfinApiClient>().createApi(
device = get<DeviceInfo>(defaultDeviceInfo).legacy()
)
}
// Coil (images)
single {
ImageLoader.Builder(androidContext()).apply {
serviceLoaderEnabled(false)
logger(CoilTimberLogger(if (BuildConfig.DEBUG) Logger.Level.Warn else Logger.Level.Error))
components {
add(get<NetworkFetcher.Factory>())
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())
}
@@ -131,35 +109,26 @@ val appModule = module {
// Non API related
single { DataRefreshService() }
single { PlaybackControllerContainer() }
single { InteractionTrackerViewModel(get(), get()) }
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()) }
single<ExternalAppRepository> { ExternalAppRepository(get()) }
viewModel { StartupViewModel(get(), get(), get(), get()) }
viewModel { UserLoginViewModel(get(), get(), get(), get(defaultDeviceInfo)) }
viewModel { ServerAddViewModel(get()) }
viewModel { NextUpViewModel(get(), get(), get()) }
viewModel { StillWatchingViewModel(get(), get(), get(), get()) }
viewModel { PhotoPlayerViewModel(get()) }
viewModel { NextUpViewModel(get(), get(), get(), get()) }
viewModel { PictureViewerViewModel(get()) }
viewModel { ScreensaverViewModel(get()) }
viewModel { SearchViewModel(get()) }
viewModel { DreamViewModel(get(), get(), get(), get(), get()) }
viewModel { SettingsViewModel() }
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()) }
factory { (context: Context) -> SearchFragmentDelegate(context, get(), get()) }
factory { (context: Context) -> SearchFragmentDelegate(context, get()) }
}

View File

@@ -1,5 +1,7 @@
package org.jellyfin.androidtv.di
import org.jellyfin.androidtv.auth.AccountManagerMigration
import org.jellyfin.androidtv.auth.apiclient.ApiBinder
import org.jellyfin.androidtv.auth.repository.AuthenticationRepository
import org.jellyfin.androidtv.auth.repository.AuthenticationRepositoryImpl
import org.jellyfin.androidtv.auth.repository.ServerRepository
@@ -13,7 +15,8 @@ import org.jellyfin.androidtv.auth.store.AuthenticationStore
import org.koin.dsl.module
val authModule = module {
single { AuthenticationStore(get()) }
single { AccountManagerMigration(get()) }
single { AuthenticationStore(get(), get()) }
single { AuthenticationPreferences(get()) }
single<AuthenticationRepository> {
@@ -22,11 +25,8 @@ val authModule = module {
single<ServerRepository> { ServerRepositoryImpl(get(), get()) }
single<ServerUserRepository> { ServerUserRepositoryImpl(get(), get()) }
single<SessionRepository> {
SessionRepositoryImpl(get(), get(), get(), get(), get(defaultDeviceInfo), get(), get(), get())
SessionRepositoryImpl(get(), get(), get(), get(), get(), get(defaultDeviceInfo), get(), get(), get())
}
factory {
val serverRepository = get<ServerRepository>()
serverRepository.currentServer.value?.serverVersion ?: ServerRepository.minimumServerVersion
}
single { ApiBinder(get(), get()) }
}

View File

@@ -6,88 +6,75 @@ import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.media3.datasource.HttpDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.R
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.MediaManager
import org.jellyfin.androidtv.ui.playback.PlaybackLauncher
import org.jellyfin.androidtv.ui.playback.GarbagePlaybackLauncher
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.androidtv.util.profile.createDeviceProfile
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.HttpClientOptions
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.model.api.MediaSegmentType
import org.koin.android.ext.koin.androidContext
import org.koin.core.scope.Scope
import org.koin.dsl.module
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import org.jellyfin.androidtv.ui.playback.PlaybackManager as LegacyPlaybackManager
val playbackModule = module {
single { LegacyPlaybackManager(get()) }
single { VideoQueueManager() }
single<MediaManager> { RewriteMediaManager(get(), get()) }
single { LegacyMediaManager(get()) }
single { RewriteMediaManager(get(), get(), get(), get()) }
single { PlaybackLauncher(get(), get(), get(), get()) }
factory {
val preferences = get<UserPreferences>()
val useRewrite = preferences[UserPreferences.playbackRewriteAudioEnabled]
single<HttpDataSource.Factory> {
val okHttpFactory = get<OkHttpFactory>()
val httpClientOptions = get<HttpClientOptions>().copy(
// Disable request timeout for media playback as this causes issues with Live TV
requestTimeout = Duration.ZERO
)
if (useRewrite) get<RewriteMediaManager>()
else get<LegacyMediaManager>()
}
OkHttpDataSource.Factory(okHttpFactory.createClient(httpClientOptions))
factory {
val preferences = get<UserPreferences>()
val useRewrite = preferences[UserPreferences.playbackRewriteVideoEnabled] && BuildConfig.DEVELOPMENT
if (useRewrite) RewritePlaybackLauncher()
else GarbagePlaybackLauncher(get())
}
single { createPlaybackManager() }
}
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,
notificationChannelId,
NotificationManager.IMPORTANCE_LOW
)
channel.setShowBadge(false)
NotificationManagerCompat.from(get()).createNotificationChannel(channel)
}
val userPreferences = get<UserPreferences>()
val exoPlayerOptions = ExoPlayerOptions(
preferFfmpeg = userPreferences[UserPreferences.preferExoPlayerFfmpeg],
enableLibass = userPreferences[UserPreferences.assDirectPlay],
enableDebugLogging = userPreferences[UserPreferences.debuggingEnabled],
baseDataSourceFactory = get<HttpDataSource.Factory>(),
)
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))
val deviceProfileBuilder = { createDeviceProfile(androidContext(), userPreferences, get()) }
install(jellyfinPlugin(get(), deviceProfileBuilder, setOf(MediaSegmentType.INTRO), ProcessLifecycleOwner.get().lifecycle))
)))
// 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

@@ -7,10 +7,8 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.core.content.edit
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import androidx.tvprovider.media.tv.Channel
import androidx.tvprovider.media.tv.ChannelLogoUtils
import androidx.tvprovider.media.tv.PreviewProgram
@@ -23,38 +21,36 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.data.repository.ItemRepository
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.apiclient.getUrl
import org.jellyfin.androidtv.util.apiclient.itemImages
import org.jellyfin.androidtv.util.apiclient.parentImages
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
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.BaseItemDto
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.MediaType
import org.jellyfin.sdk.model.api.ItemFields
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.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import kotlin.time.Duration
/**
* Manages channels on the android tv home screen.
*
@@ -71,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.
@@ -102,39 +97,39 @@ class LeanbackChannelWorker(
// Get channel URIs
val latestMediaChannel = getChannelUri(
"latest_media", Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.home_section_latest_media))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build(),
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.home_section_latest_media))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build(),
default = true
)
val myMediaChannel = getChannelUri(
"my_media", Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.lbl_my_media))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.lbl_my_media))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build()
)
val nextUpChannel = getChannelUri(
"next_up", Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.lbl_next_up))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.lbl_next_up))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build()
)
val latestMoviesChannel = getChannelUri(
"latest_movies", Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.lbl_movies))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.lbl_movies))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build()
)
val latestEpisodesChannel = getChannelUri(
"latest_episodes", Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.lbl_new_episodes))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build()
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(context.getString(R.string.lbl_new_episodes))
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
.build()
)
val preferParentThumb = userPreferences[UserPreferences.seriesThumbnailsEnabled]
@@ -146,21 +141,18 @@ class LeanbackChannelWorker(
latestEpisodes to latestEpisodesChannel,
myMedia to myMediaChannel,
).forEach { (items, channel) ->
if (channel == null) {
Timber.e("Skipping channel because it was not available")
} else {
items.map { item ->
createPreviewProgram(
channel,
item,
preferParentThumb
)
}.let {
context.contentResolver.bulkInsert(
TvContractCompat.PreviewPrograms.CONTENT_URI,
it.toTypedArray()
)
}
Timber.d("Updating channel %s", channel)
items.map { item ->
createPreviewProgram(
channel,
item,
preferParentThumb
)
}.let {
context.contentResolver.bulkInsert(
TvContractCompat.PreviewPrograms.CONTENT_URI,
it.toTypedArray()
)
}
}
updateWatchNext(resumeItems + nextUpItems)
@@ -183,46 +175,35 @@ class LeanbackChannelWorker(
* update or create the channel. The [name] parameter is used to store the id and should be
* unique.
*/
private fun getChannelUri(name: String, settings: Channel, default: Boolean = false): Uri? {
private fun getChannelUri(name: String, settings: Channel, default: Boolean = false): Uri {
val store = context.getSharedPreferences("leanback_channels", Context.MODE_PRIVATE)
var uri: Uri? = null
// Try and re-use our existing channel definition
if (store.contains(name)) {
uri = store.getString(name, null)?.toUri()
if (uri != null) {
val result = context.contentResolver.update(uri, settings.toContentValues(), null, null)
// If we did not affect exactly 1 row there might be something wrong, so recreate it
if (result != 1) uri = null
val uri = if (store.contains(name)) {
// Retrieve uri and update content resolver
Uri.parse(store.getString(name, null)).also { uri ->
context.contentResolver.update(uri, settings.toContentValues(), null, null)
}
}
if (uri == null) {
// Create new channel
uri = context.contentResolver.insert(
} else {
// Create new channel and save uri
context.contentResolver.insert(
TvContractCompat.Channels.CONTENT_URI,
settings.toContentValues()
)
// Set as default row to display (we can request one row to automatically be added to the home screen)
if (uri != null && default) {
TvContractCompat.requestChannelBrowsable(context, ContentUris.parseId(uri))
)!!.also { uri ->
store.edit().putString(name, uri.toString()).apply()
if (default) {
// Set as default row to display (we can request one row to automatically be added to the home screen)
TvContractCompat.requestChannelBrowsable(context, ContentUris.parseId(uri))
}
}
// Save uri to shared preferences
store.edit { putString(name, uri?.toString()) }
}
// Update logo
if (uri != null) {
ResourcesCompat.getDrawable(context.resources, R.mipmap.app_icon, context.theme)?.let {
ChannelLogoUtils.storeChannelLogo(
context,
ContentUris.parseId(uri),
it.toBitmap(80.dp(context), 80.dp(context))
)
}
ResourcesCompat.getDrawable(context.resources, R.mipmap.app_icon, context.theme)?.let {
ChannelLogoUtils.storeChannelLogo(
context,
ContentUris.parseId(uri),
it.toBitmap(80.dp(context), 80.dp(context))
)
}
return uri
@@ -237,6 +218,7 @@ class LeanbackChannelWorker(
// Add new items
return response.items
.orEmpty()
.filter { userViewsRepository.isSupported(it.collectionType) }
}
@@ -247,12 +229,35 @@ class LeanbackChannelWorker(
private fun BaseItemDto.getPosterArtImageUrl(
preferParentThumb: Boolean
): Uri = when {
type == BaseItemKind.MOVIE || type == BaseItemKind.SERIES -> itemImages[ImageType.PRIMARY]
(preferParentThumb || !itemImages.contains(ImageType.PRIMARY)) && parentImages.contains(ImageType.THUMB) -> parentImages[ImageType.THUMB]
else -> itemImages[ImageType.PRIMARY]
}.let { image ->
ImageProvider.getImageUri(image?.getUrl(api) ?: imageHelper.getResourceUrl(context, R.drawable.tile_land_tv))
}
type == BaseItemKind.MOVIE || type == BaseItemKind.SERIES -> api.imageApi.getItemImageUrl(
itemId = id,
imageType = ImageType.PRIMARY,
format = ImageFormat.WEBP,
width = 106.dp(context),
height = 153.dp(context),
tag = imageTags?.get(ImageType.PRIMARY),
)
(preferParentThumb || imageTags?.contains(ImageType.PRIMARY) != true) && parentThumbItemId != null -> api.imageApi.getItemImageUrl(
itemId = parentThumbItemId!!,
imageType = ImageType.THUMB,
format = ImageFormat.WEBP,
width = 272.dp(context),
height = 153.dp(context),
tag = imageTags?.get(ImageType.THUMB),
)
imageTags?.containsKey(ImageType.PRIMARY) == true -> api.imageApi.getItemImageUrl(
itemId = id,
imageType = ImageType.PRIMARY,
format = ImageFormat.WEBP,
width = 272.dp(context),
height = 153.dp(context),
tag = imageTags?.get(ImageType.PRIMARY),
)
else -> ImageUtils.getResourceUrl(context, R.drawable.tile_land_tv)
}.let(ImageProvider::getImageUri)
/**
* Gets the resume and next up episodes. The returned pair contains two lists:
@@ -263,22 +268,22 @@ class LeanbackChannelWorker(
withContext(Dispatchers.IO) {
val resume = async {
api.itemsApi.getResumeItems(
fields = ItemRepository.itemFields,
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 = ItemRepository.itemFields,
).content.items
fields = listOf(ItemFields.DATE_CREATED),
).content.items.orEmpty()
}
// Concat
@@ -289,7 +294,9 @@ class LeanbackChannelWorker(
withContext(Dispatchers.IO) {
val latestEpisodes = async {
api.userLibraryApi.getLatestMedia(
fields = ItemRepository.itemFields,
fields = listOf(
ItemFields.OVERVIEW,
),
limit = 50,
includeItemTypes = listOf(BaseItemKind.EPISODE),
isPlayed = false
@@ -298,7 +305,9 @@ class LeanbackChannelWorker(
val latestMovies = async {
api.userLibraryApi.getLatestMedia(
fields = ItemRepository.itemFields,
fields = listOf(
ItemFields.OVERVIEW,
),
limit = 50,
includeItemTypes = listOf(BaseItemKind.MOVIE),
isPlayed = false
@@ -307,7 +316,9 @@ class LeanbackChannelWorker(
val latestMedia = async {
api.userLibraryApi.getLatestMedia(
fields = ItemRepository.itemFields,
fields = listOf(
ItemFields.OVERVIEW,
),
limit = 50,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
isPlayed = false
@@ -348,11 +359,23 @@ class LeanbackChannelWorker(
)
.setTitle(item.seriesName ?: item.name)
.setEpisodeTitle(if (item.type == BaseItemKind.EPISODE) item.name else null)
.setDescription(item.overview?.stripHtml())
.setSeasonNumber(seasonString, item.parentIndexNumber ?: 0)
.setEpisodeNumber(episodeString, item.indexNumber ?: 0)
.setDescription(item.overview)
.setReleaseDate(
if (item.premiereDate != null) DateTimeFormatter.ISO_DATE.format(item.premiereDate)
else null
)
.setDurationMillis(
if (item.runTimeTicks?.ticks != null) {
// If we are resuming, we need to show remaining time, cause GoogleTV
// ignores setLastPlaybackPositionMillis
val duration = item.runTimeTicks?.ticks ?: Duration.ZERO
val playbackPosition = item.userData?.playbackPositionTicks?.ticks
?: Duration.ZERO
(duration - playbackPosition).inWholeMilliseconds.toInt()
} else 0
)
.setPosterArtUri(imageUri)
.setPosterArtAspectRatio(
when (item.type) {
@@ -366,22 +389,8 @@ class LeanbackChannelWorker(
putExtra(StartupActivity.EXTRA_ITEM_ID, item.id.toString())
putExtra(StartupActivity.EXTRA_ITEM_IS_USER_VIEW, item.type == BaseItemKind.COLLECTION_FOLDER)
})
.setDurationMillis(
if (item.runTimeTicks?.ticks != null) {
// If we are resuming, we need to show remaining time, cause GoogleTV
// ignores setLastPlaybackPositionMillis
val duration = item.runTimeTicks?.ticks ?: Duration.ZERO
val playbackPosition = item.userData?.playbackPositionTicks?.ticks
?: Duration.ZERO
(duration - playbackPosition).inWholeMilliseconds.toInt()
} else 0
)
.apply {
if ((item.parentIndexNumber ?: 0) > 0)
setSeasonNumber(seasonString, item.parentIndexNumber!!)
if ((item.indexNumber ?: 0) > 0)
setEpisodeNumber(episodeString, item.indexNumber!!)
}.build().toContentValues()
.build()
.toContentValues()
}
/**
@@ -389,57 +398,16 @@ class LeanbackChannelWorker(
* or other types of media. Uses the [nextUpItems] parameter to store items returned by a
* NextUpQuery().
*/
@SuppressLint("RestrictedApi")
private fun updateWatchNext(nextUpItems: List<BaseItemDto>) {
deletePrograms(nextUpItems)
// Delete current items
context.contentResolver.delete(WatchNextPrograms.CONTENT_URI, null, null)
// Get current watch next state
val currentWatchNextPrograms = getCurrentWatchNext()
// Create all programs in nextUpItems but not in watch next
val programsToAdd = nextUpItems
.filter { next -> currentWatchNextPrograms.none { it.internalProviderId == next.id.toString() } }
// Add new items
context.contentResolver.bulkInsert(
WatchNextPrograms.CONTENT_URI,
programsToAdd.map { item -> getBaseItemAsWatchNextProgram(item).toContentValues() }
.toTypedArray())
}
/**
* Delete stale programs from the watch next row. Items that don't need to be touched are
* kept as is, so they keep their ordering in the watch next row.
*/
@SuppressLint("RestrictedApi")
private fun deletePrograms(nextUpItems: List<BaseItemDto>) {
// Retrieve current watch next row
val currentWatchNextPrograms = getCurrentWatchNext()
// Find all stale programs to delete
val deletedByUser = currentWatchNextPrograms.filter { !it.isBrowsable }
val noLongerInWatchNext =
currentWatchNextPrograms.filter { (nextUpItems).none { next -> it.internalProviderId == next.id.toString() } }
val continueWatching = currentWatchNextPrograms.filter { it.watchNextType == WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE }
// Delete the programs
(deletedByUser + noLongerInWatchNext + continueWatching)
.forEach { context.contentResolver.delete(TvContractCompat.buildWatchNextProgramUri(it.id), null, null) }
}
/**
* Retrieves the current watch next row state.
*/
@SuppressLint("RestrictedApi")
private fun getCurrentWatchNext(): MutableList<WatchNextProgram> {
val currentWatchNextPrograms: MutableList<WatchNextProgram> = mutableListOf()
context.contentResolver.query(WatchNextPrograms.CONTENT_URI, WatchNextProgram.PROJECTION, null, null, null)
.use { cursor ->
if (cursor != null && cursor.moveToFirst()) {
do {
currentWatchNextPrograms.add(WatchNextProgram.fromCursor(cursor))
} while (cursor.moveToNext())
}
}
return currentWatchNextPrograms
nextUpItems.map { item -> getBaseItemAsWatchNextProgram(item).toContentValues() }
.toTypedArray()
)
}
/**
@@ -461,51 +429,39 @@ class LeanbackChannelWorker(
setPosterArtAspectRatio(WatchNextPrograms.ASPECT_RATIO_MOVIE_POSTER)
}
// Name and episode details
if (item.seriesName != null) {
setTitle(item.seriesName)
setEpisodeTitle(item.name)
item.indexNumber?.takeIf { it > 0 }?.let { setEpisodeNumber(it) }
item.parentIndexNumber?.takeIf { it > 0 }?.let { setSeasonNumber(it) }
} else {
setTitle(item.name)
}
setDescription(item.overview?.stripHtml())
// Name
if (item.seriesName != null) setTitle("${item.seriesName} - ${item.name}")
else setTitle(item.name)
// Poster
setPosterArtUri(item.getPosterArtImageUrl(preferParentThumb))
// Use date created or fallback to current time if unavailable
var engagement = item.dateCreated
when {
// User has started playing the episode
(item.userData?.playbackPositionTicks ?: 0) > 0 -> {
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE)
setLastPlaybackPositionMillis(item.userData!!.playbackPositionTicks.ticks.inWholeMilliseconds.toInt())
// Use last played date to prioritize
setLastEngagementTimeUtcMillis(
item.userData?.lastPlayedDate?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
?: Instant.now().toEpochMilli()
)
engagement = item.userData?.lastPlayedDate
}
// First episode of the season
item.indexNumber == 1 -> {
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEW)
setLastEngagementTimeUtcMillis(
item.dateCreated?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli()
?: Instant.now().toEpochMilli()
)
}
item.indexNumber == 1 -> setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEW)
// Default
else -> {
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT)
setLastEngagementTimeUtcMillis(Instant.now().toEpochMilli())
}
else -> setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT)
}
// Runtime has been determined
item.runTimeTicks?.ticks?.let { setDurationMillis(it.inWholeMilliseconds.toInt()) }
setLastEngagementTimeUtcMillis(
engagement?.toInstant(ZoneOffset.UTC)?.toEpochMilli()
?: System.currentTimeMillis()
)
// Episode runtime has been determined
item.runTimeTicks?.let { runTimeTicks ->
setDurationMillis(runTimeTicks.ticks.inWholeMilliseconds.toInt())
}
// Set intent to open the episode
setIntent(Intent(context, StartupActivity::class.java).apply {

View File

@@ -9,22 +9,19 @@ import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.provider.BaseColumns
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.data.repository.ItemRepository
import org.jellyfin.androidtv.integration.provider.ImageProvider
import org.jellyfin.androidtv.util.ImageHelper
import org.jellyfin.androidtv.util.apiclient.getUrl
import org.jellyfin.androidtv.util.apiclient.itemImages
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
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFields
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import timber.log.Timber
@@ -44,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
@@ -64,9 +60,8 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
val limit = uri.getQueryParameter(SearchManager.SUGGEST_PARAMETER_LIMIT)?.toIntOrNull()
?: DEFAULT_LIMIT
return runBlocking { getSuggestions(query, limit) }
return getSuggestions(query, limit)
}
else -> throw IllegalArgumentException("Unknown Uri: $uri")
}
}
@@ -75,11 +70,11 @@ 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,
fields = ItemRepository.itemFields
fields = setOf(ItemFields.TAGLINES)
)
items
@@ -88,7 +83,7 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
null
}
private suspend fun getSuggestions(query: String, limit: Int) = withContext(Dispatchers.IO) {
private fun getSuggestions(query: String, limit: Int) = runBlocking {
val searchResult = searchItems(query, limit)
if (searchResult != null) Timber.d("Query resulted in %d items", searchResult.totalRecordCount)
@@ -108,10 +103,10 @@ class MediaContentProvider : ContentProvider(), KoinComponent {
MatrixCursor(columns).also { cursor ->
searchResult?.items?.forEach { item ->
val imageUri = ImageProvider.getImageUri(
item.itemImages[ImageType.PRIMARY]?.getUrl(api)
?: imageHelper.getResourceUrl(context!!, R.drawable.tile_land_tv)
)
val imageUri = if (item.imageTags?.contains(ImageType.PRIMARY) == true)
ImageProvider.getImageUri(api.imageApi.getItemImageUrl(item.id, ImageType.PRIMARY))
else
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,160 +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.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.cancellable
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
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.androidtv.util.apiclient.getUrl
import org.jellyfin.androidtv.util.apiclient.itemBackdropImages
import org.jellyfin.androidtv.util.apiclient.itemImages
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.itemsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
import timber.log.Timber
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
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
?.takeIf { it.visibleInScreensaver }
?.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)
val requireParentalRating = userPreferences[UserPreferences.screensaverAgeRatingRequired]
val maxParentalRating = userPreferences[UserPreferences.screensaverAgeRatingMax]
emitAll(
getRandomLibraryShowcaseItems(
requireParentalRating = requireParentalRating,
maxParentalRating = maxParentalRating,
// A batch size of 60 should be equal to 30 minutes of items
batchSize = 60,
emitDelay = 30.seconds,
noItemsDelay = 2.minutes,
errorDelay = 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 fun getRandomLibraryShowcaseItems(
requireParentalRating: Boolean,
maxParentalRating: Int,
batchSize: Int,
emitDelay: Duration,
noItemsDelay: Duration,
errorDelay: Duration,
): Flow<DreamContent.LibraryShowcase?> = flow {
while (true) {
val items = try {
withContext(Dispatchers.IO) {
val response by api.itemsApi.getItems(
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
recursive = true,
sortBy = listOf(ItemSortBy.RANDOM),
limit = batchSize,
imageTypes = listOf(ImageType.BACKDROP),
maxOfficialRating = if (maxParentalRating == -1) null else maxParentalRating.toString(),
hasParentalRating = if (requireParentalRating) true else null,
)
response.items
}
} catch (err: ApiClientException) {
Timber.e(err)
null
}
if (items == null) {
emit(null)
delay(errorDelay)
} else if (items.isEmpty()) {
emit(null)
delay(noItemsDelay)
} else {
for (item in items) {
if (item.itemBackdropImages.isEmpty()) continue
val showcase = item.asLibraryShowcase() ?: continue
emit(showcase)
delay(emitDelay)
}
}
}
}.cancellable()
private suspend fun BaseItemDto.asLibraryShowcase(): DreamContent.LibraryShowcase? = withContext(Dispatchers.IO) {
val logoUrl = itemImages[ImageType.LOGO]?.getUrl(api)
val backdropUrl = itemBackdropImages.randomOrNull()?.getUrl(api)
// Require a backdrop
if (backdropUrl == null) return@withContext null
// Only attempt to load logo if there is one, wrap in async {} to load it parallel with the backdrop
val logo = logoUrl?.let { url ->
async {
imageLoader.execute(
request = ImageRequest.Builder(context).data(url).build()
).image?.toBitmap()
}
}
val backdrop = imageLoader.execute(
request = ImageRequest.Builder(context).data(backdropUrl).build()
).image?.toBitmap()
if (backdrop == null) null
else DreamContent.LibraryShowcase(this@asLibraryShowcase, backdrop, logo?.await())
}
}

View File

@@ -1,12 +0,0 @@
package org.jellyfin.androidtv.integration.dream
import org.jellyfin.playback.core.element.ElementKey
import org.jellyfin.playback.core.element.requiredElement
import org.jellyfin.playback.core.queue.QueueEntry
private val visibleInScreensaverKey = ElementKey<Boolean>("visibleInScreensaver")
/**
* Get or set whether this [QueueEntry] is shown in the screensaver.
*/
var QueueEntry.visibleInScreensaver by requiredElement(visibleInScreensaverKey) { false }

View File

@@ -2,10 +2,9 @@ package org.jellyfin.androidtv.integration.dream.composable
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -13,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 org.jellyfin.androidtv.integration.dream.model.DreamContent
import org.jellyfin.androidtv.ui.base.Text
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(
@@ -50,26 +47,12 @@ fun DreamContentLibraryShowcase(
.align(Alignment.BottomStart)
.overscan(),
) {
if (content.logo != null) {
BoxWithConstraints {
Image(
bitmap = content.logo.asImageBitmap(),
contentDescription = content.item.name,
modifier = Modifier
.sizeIn(
maxWidth = maxWidth * 0.35f,
maxHeight = 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,40 +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.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 org.jellyfin.androidtv.integration.dream.model.DreamContent
import org.jellyfin.androidtv.ui.base.SeekbarDefaults
import org.jellyfin.androidtv.ui.base.Text
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.androidtv.ui.player.base.PlayerSeekbar
import org.jellyfin.androidtv.util.apiclient.albumPrimaryImage
import org.jellyfin.androidtv.util.apiclient.getUrl
import org.jellyfin.androidtv.util.apiclient.itemImages
import org.jellyfin.androidtv.util.apiclient.parentImages
import org.jellyfin.playback.core.PlaybackManager
import org.jellyfin.playback.core.model.PlayState
import org.jellyfin.playback.jellyfin.lyrics.lyrics
import org.jellyfin.playback.jellyfin.lyrics.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(
@@ -54,18 +52,21 @@ fun DreamContentNowPlaying(
) = Box(
modifier = Modifier.fillMaxSize(),
) {
val api = koinInject<ApiClient>()
val playbackManager = koinInject<PlaybackManager>()
val lyrics = content.entry.run { lyricsFlow.collectAsState(lyrics) }.value
val api = rememberKoinInject<ApiClient>()
val mediaManager = rememberKoinInject<MediaManager>()
val item = content.item ?: return@Box
val primaryImage = content.item.itemImages[ImageType.PRIMARY]
?: content.item.albumPrimaryImage
?: content.item.parentImages[ImageType.PRIMARY]
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
}
// Background
if (primaryImage?.blurHash != null) {
val imageBlurHash = imageTag?.let { tag -> item.imageBlurHashes?.get(ImageType.PRIMARY)?.get(tag) }
if (imageBlurHash != null) {
Image(
painter = blurHashPainter(primaryImage.blurHash, IntSize(32, 32)),
painter = blurHashPainter(imageBlurHash, IntSize(32, 32)),
contentDescription = null,
alignment = Alignment.Center,
contentScale = ContentScale.Crop,
@@ -75,29 +76,7 @@ fun DreamContentNowPlaying(
DreamContentVignette()
}
// Lyrics overlay (on top of background)
if (lyrics != null) {
val playState by remember { playbackManager.state.playState }.collectAsState()
// Using the progress animation causes the layout to recompose, which we need for synced lyrics to work
// we don't actually use the animation value here
rememberPlayerProgress(playbackManager)
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),
@@ -105,10 +84,15 @@ fun DreamContentNowPlaying(
.align(Alignment.BottomStart)
.overscan(),
) {
if (primaryImage != null) {
if (imageItemId != null) {
AsyncImage(
url = primaryImage.getUrl(api),
blurHash = primaryImage.blurHash,
url = api.imageApi.getItemImageUrl(
itemId = imageItemId,
imageType = ImageType.PRIMARY,
tag = imageTag,
format = ImageFormat.WEBP,
),
blurHash = imageBlurHash,
scaleType = ImageView.ScaleType.CENTER_CROP,
modifier = Modifier
.size(128.dp)
@@ -121,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,
@@ -129,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,
@@ -147,16 +127,28 @@ fun DreamContentNowPlaying(
Spacer(modifier = Modifier.height(10.dp))
PlayerSeekbar(
playbackManager = playbackManager,
colors = SeekbarDefaults.colors(
backgroundColor = Color.White.copy(alpha = 0.2f),
progressColor = Color.White,
bufferColor = Color.Transparent,
),
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
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

@@ -1,32 +1,72 @@
package org.jellyfin.androidtv.integration.dream.composable
import android.text.format.DateUtils
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
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.Alignment
import androidx.compose.ui.Modifier
import org.jellyfin.androidtv.ui.composable.modifier.overscan
import org.jellyfin.androidtv.ui.shared.toolbar.ToolbarClock
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
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 org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.composable.overscan
import org.jellyfin.androidtv.ui.composable.rememberCurrentTime
@Composable
fun DreamHeader(
showLogo: Boolean,
showClock: Boolean,
) = Box(
modifier = Modifier
.fillMaxWidth()
.overscan(),
) {
// Clock
AnimatedVisibility(
visible = showClock,
enter = fadeIn(),
exit = fadeOut(),
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.align(Alignment.TopEnd),
.fillMaxWidth()
.overscan(),
) {
ToolbarClock()
// Logo
AnimatedVisibility(
visible = showLogo,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.height(41.dp),
) {
Image(
painter = painterResource(R.drawable.app_logo),
contentDescription = stringResource(R.string.app_name),
)
}
Spacer(
modifier = Modifier
.fillMaxWidth(0f)
)
// Clock
AnimatedVisibility(
visible = showClock,
enter = fadeIn(),
exit = fadeOut(),
) {
val time = rememberCurrentTime()
Text(
text = DateUtils.formatDateTime(LocalContext.current, time, DateUtils.FORMAT_SHOW_TIME),
style = TextStyle(
color = Color.White,
fontSize = 20.sp
),
)
}
}
}

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,
@@ -36,6 +38,7 @@ fun DreamView(
// Header overlay
DreamHeader(
showLogo = content != DreamContent.Logo,
showClock = showClock,
)
}

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

@@ -1,27 +1,23 @@
package org.jellyfin.androidtv.preference
import android.content.Context
import android.view.KeyEvent
import androidx.preference.PreferenceManager
import org.jellyfin.androidtv.preference.UserPreferences.Companion.screensaverInAppEnabled
import org.jellyfin.androidtv.preference.constant.AppTheme
import org.jellyfin.androidtv.preference.constant.AudioBehavior
import org.jellyfin.androidtv.preference.constant.BackdropBehavior
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.StillWatchingBehavior
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
/**
@@ -34,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
@@ -41,13 +42,24 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
var appTheme = enumPreference("app_theme", AppTheme.DARK)
/**
* Behavior of app background while browsing
* Enable background images while browsing
*/
var backdropBehavior = enumPreference("backdrop_behavior", BackdropBehavior.BACKDROP_WITH_BLUR)
var backdropEnabled = booleanPreference("pref_show_backdrop", true)
/**
* Show premieres on home screen
*/
var premieresEnabled = booleanPreference("pref_enable_premieres", false)
/**
* 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")
@@ -77,47 +89,47 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
*/
var cinemaModeEnabled = booleanPreference("pref_enable_cinema_mode", true)
/**
* Enable still watching
*/
var stillWatchingBehavior = enumPreference("enable_still_watching", StillWatchingBehavior.DISABLED)
/* Playback - Video */
/**
* Whether to use an external playback application or not.
* Preferred video player.
*/
var useExternalPlayer = booleanPreference("external_player", false)
/**
* Component name for the external playback application.
*/
var externalPlayerComponentName = stringPreference("external_player_component", "")
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 */
/**
@@ -125,6 +137,21 @@ 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
*/
var shortcutAudioTrack = intPreference("shortcut_audio_track", KeyEvent.KEYCODE_MEDIA_AUDIO_TRACK)
/**
* Shortcut used for changing the subtitle track
*/
var shortcutSubtitleTrack = intPreference("shortcut_subtitle_track", KeyEvent.KEYCODE_CAPTIONS)
/* Developer options */
/**
* Show additional debug information
@@ -141,6 +168,11 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
*/
var clockBehavior = enumPreference("pref_clock_behavior", ClockBehavior.ALWAYS)
/**
* Set which ratings provider should show on MyImageCardViews
*/
var defaultRatingType = enumPreference("pref_rating_type", RatingType.RATING_TOMATOES)
/**
* Set when watched indicators should show on MyImageCardViews
*/
@@ -152,35 +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 bold text
* Subtitles font size
*/
var subtitlesTextWeight = intPreference("subtitles_text_weight", 400)
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", 24f)
/**
* Subtitles offset
*/
var subtitlesOffsetPosition = floatPreference("subtitles_offset_position", 0.08f)
/**
* Show screensaver in app
*/
@@ -192,50 +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)
/**
* Enable libass.
*/
var assDirectPlay = booleanPreference("libass_enabled", false)
/**
* Enable PGS subtitle direct-play.
*/
var pgsDirectPlay = booleanPreference("pgs_enabled", true)
var homeReactive = booleanPreference("home_reactive", false)
}
init {
@@ -248,30 +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)
}
// v0.19.0 to v0.20.0
migration(toVersion = 9) {
// Reset subtitle text size as we changed from fractional sizing to absolute sizing
remove("subtitles_text_size")
// Set the BackdropBehavior if it was enabled in a previous version
val backdropEnabled = it.getBoolean("pref_show_backdrop", true)
putString(
"backdrop_behavior",
if (backdropEnabled) BackdropBehavior.BACKDROP_WITH_BLUR.name else BackdropBehavior.DISABLED.name
)
}
}
}
}

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

@@ -1,20 +0,0 @@
package org.jellyfin.androidtv.preference.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
enum class BackdropBehavior(
override val nameRes: Int,
) : PreferenceEnum {
DISABLED(R.string.state_disabled),
/**
* When selected, backdrop is shown with a blur effect.
*/
BACKDROP_WITH_BLUR(R.string.backdrop_behavior_with_blur),
/**
* When selected, backdrop is shown without any blur effect.
*/
BACKDROP_WITHOUT_BLUR(R.string.backdrop_behavior_without_blur),
}

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

@@ -0,0 +1,23 @@
package org.jellyfin.androidtv.preference.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
enum class RatingType(
override val nameRes: Int,
) : PreferenceEnum {
/**
* Sets default rating type to tomatoes.
*/
RATING_TOMATOES(R.string.lbl_tomatoes),
/**
* Sets the default rating type to stars.
*/
RATING_STARS(R.string.lbl_stars),
/**
* Sets the default rating type to hidden.
*/
RATING_HIDDEN(R.string.lbl_hidden),
}

View File

@@ -1,63 +0,0 @@
package org.jellyfin.androidtv.preference.constant
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
enum class StillWatchingBehavior(
override val nameRes: Int,
val enabled: Boolean,
val episodeCount: Int,
val minDuration: Duration,
) : PreferenceEnum {
/**
* Shorter than default. Show screen at 2 episodes or 60 minutes of uninterrupted watch time.
*/
SHORT(
nameRes = R.string.lbl_still_watching_short,
enabled = true,
episodeCount = 2,
minDuration = 1.hours,
),
/**
* Default behavior for still watching. Show screen at 3 episodes or 90 minutes of uninterrupted watch time.
*/
DEFAULT(
nameRes = R.string.lbl_still_watching_default,
enabled = true,
episodeCount = 3,
minDuration = 1.5.hours,
),
/**
* Longer than default. Show screen at 5 episodes or 150 minutes of uninterrupted watch time.
*/
LONG(
nameRes = R.string.lbl_still_watching_long,
enabled = true,
episodeCount = 5,
minDuration = 2.5.hours,
),
/**
* Much longer than default. Show screen at 8 episodes or 240 minutes of uninterrupted watch time.
*/
VERY_LONG(
nameRes = R.string.lbl_still_watching_very_long,
enabled = true,
episodeCount = 8,
minDuration = 4.hours,
),
/**
* Disables still watching screen.
*/
DISABLED(
nameRes = R.string.state_disabled,
enabled = false,
episodeCount = 0,
minDuration = Duration.ZERO,
)
}

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

@@ -1,7 +1,5 @@
package org.jellyfin.androidtv.preference.store
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.preference.Preference
import org.jellyfin.preference.PreferenceEnum
import org.jellyfin.preference.migration.MigrationContext
@@ -58,12 +56,10 @@ abstract class DisplayPreferencesStore(
override suspend fun update(): Boolean {
try {
val result = withContext(Dispatchers.IO) {
api.displayPreferencesApi.getDisplayPreferences(
displayPreferencesId = displayPreferencesId,
client = app
).content
}
val result by api.displayPreferencesApi.getDisplayPreferences(
displayPreferencesId = displayPreferencesId,
client = app
)
displayPreferencesDto = result
cachedPreferences = result.customPrefs.toMutableMap()
@@ -86,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
@@ -103,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()
}
@@ -130,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

@@ -18,11 +18,6 @@ import org.acra.sender.ReportSenderFactory
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.preference.TelemetryPreferences
import org.jellyfin.androidtv.util.appendCodeBlock
import org.jellyfin.androidtv.util.appendItem
import org.jellyfin.androidtv.util.appendSection
import org.jellyfin.androidtv.util.appendValue
import org.jellyfin.androidtv.util.buildMarkdown
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
import java.net.HttpURLConnection
import java.net.URL
@@ -68,12 +63,8 @@ object TelemetryService {
// Create connection
val connection = URL(url).openConnection() as HttpURLConnection
// Add authorization
val clientName = buildString {
append("Jellyfin Android TV")
if (BuildConfig.DEBUG) append(" (debug)")
}
val authorization = AuthorizationHeaderBuilder.buildHeader(
clientName = clientName,
clientName = BuildConfig.APPLICATION_ID,
clientVersion = BuildConfig.VERSION_NAME,
deviceId = "",
deviceName = "",
@@ -94,7 +85,31 @@ object TelemetryService {
throw ReportSenderException("Unable to send crash report to server", e)
}
private fun CrashReportData.toReport(): String = buildMarkdown {
private fun StringBuilder.appendSection(name: String, content: StringBuilder.() -> Unit) {
appendLine("### $name")
appendLine()
content()
appendLine()
}
private fun StringBuilder.appendItem(name: String, value: StringBuilder.() -> Unit) {
append("***$name***: ")
value()
appendLine(" ")
}
private fun StringBuilder.appendCodeBlock(language: String, code: String?) {
appendLine()
appendLine("```$language")
appendLine(code ?: "<null>")
append("```")
}
private fun StringBuilder.appendValue(value: String?) {
append("`", value ?: "<null>", "`")
}
private fun CrashReportData.toReport(): String = buildString {
// Header
appendLine("---")
appendLine("client: Jellyfin for Android TV")
@@ -103,7 +118,6 @@ object TelemetryService {
appendLine("type: crash_report")
appendLine("format: markdown")
appendLine("---")
appendLine()
// Content
appendSection("Logs") {

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