Compare commits

...

394 Commits

Author SHA1 Message Date
renovate[bot]
f4bd3afd1b Update github/codeql-action action to v3.27.6 2024-12-05 17:08:28 +01:00
Niels van Velzen
006a6d008f Fix crash when trying to report playback progress when there is no current stream 2024-12-05 17:06:10 +01:00
Niels van Velzen
2fea54658e Fix crash when viewing audio now playing while using Emerald theme 2024-12-05 17:05:59 +01:00
Niels van Velzen
33f11515c9 Fix crash when restoring DestinationFragmentView state 2024-12-05 17:05:50 +01:00
lanklaas
f4a291f38c Translated using Weblate (Afrikaans)
Currently translated at 1.6% (9 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/af/
2024-12-05 04:00:03 -05:00
lanklaas
eff17801bd Added translation using Weblate (Afrikaans) 2024-12-05 01:16:16 -05:00
YFDOOR
5eabe9f43d Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (535 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/zh_Hans/
2024-12-03 21:00:02 -05:00
John Doe
5c3d296580 Translated using Weblate (Finnish)
Currently translated at 99.6% (533 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/fi/
2024-12-03 16:00:03 -05:00
renovate[bot]
b3068e52f7 Update dependency com.android.tools.build:gradle to v8.7.3 2024-12-02 22:09:07 +01:00
Benedict Endemann
33d24175aa Translated using Weblate (German)
Currently translated at 100.0% (535 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/de/
2024-12-01 18:00:02 -05:00
Niels van Velzen
79a97c3f4a Specify fields in resumePlayback() API request 2024-11-29 21:19:08 +01:00
John Doe
e60c2a49de Translated using Weblate (Finnish)
Currently translated at 95.3% (510 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/fi/
2024-11-29 01:00:02 -05:00
Aidas
952a53a54b Translated using Weblate (Lithuanian)
Currently translated at 79.4% (425 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/lt/
2024-11-28 17:00:02 -05:00
Aidas
1ec062fcd4 Translated using Weblate (Lithuanian)
Currently translated at 79.2% (424 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/lt/
2024-11-27 18:00:01 -05:00
HanaO00
f90ef25386 Translated using Weblate (French)
Currently translated at 100.0% (535 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/fr/
2024-11-27 13:00:01 -05:00
Aidas
c734f30bd9 Translated using Weblate (Lithuanian)
Currently translated at 79.0% (423 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/lt/
2024-11-27 04:00:02 -05:00
Aidas
d16615796d Translated using Weblate (Lithuanian)
Currently translated at 77.7% (416 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/lt/
2024-11-26 14:00:02 -05:00
Simon Hung
a37995f770 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (535 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/zh_Hant/
2024-11-26 14:00:01 -05:00
Thunderstrike116
9e45391850 Translated using Weblate (Greek)
Currently translated at 96.2% (515 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/el/
2024-11-26 14:00:01 -05:00
Aidas
3a4a4ada1c Translated using Weblate (Lithuanian)
Currently translated at 76.6% (410 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/lt/
2024-11-26 11:18:31 -05:00
renovate[bot]
1caaf3de67 Update coil to v3.0.4 2024-11-26 08:38:56 +01:00
Andi Chandler
4928d26d8b Translated using Weblate (English (United Kingdom))
Currently translated at 100.0% (535 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/en_GB/
2024-11-25 11:00:01 -05:00
xephos1one
82089d2734 Translated using Weblate (German)
Currently translated at 100.0% (535 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/de/
2024-11-22 09:00:01 -05:00
renovate[bot]
b16f4b8667 Update dependency gradle to v8.11.1 2024-11-20 19:35:48 +01:00
renovate[bot]
50088ef5cd Update github/codeql-action action to v3.27.5 2024-11-20 19:35:02 +01:00
Justin
57b918433f Translated using Weblate (German)
Currently translated at 100.0% (535 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/de/
2024-11-19 19:00:01 -05:00
Niels van Velzen
8c2caf627f Update dependency org.jellyfin.sdk:jellyfin-core to v1.6.3 2024-11-19 16:04:19 +01:00
Muhammad Zufar
b2711084c7 Translated using Weblate (Indonesian)
Currently translated at 93.6% (501 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/id/
2024-11-19 10:00:01 -05:00
Ace
7807bb32f2 Translated using Weblate (Swedish)
Currently translated at 100.0% (535 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/sv/
2024-11-18 18:00:01 -05:00
renovate[bot]
2218246478 Update gradle/actions action to v4.2.1 2024-11-18 21:59:13 +01:00
Andi Chandler
0ec4d4e4bf Translated using Weblate (English (United Kingdom))
Currently translated at 99.8% (534 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/en_GB/
2024-11-17 19:00:01 -05:00
koreapyj
1a95759d59 Translated using Weblate (Korean)
Currently translated at 99.6% (533 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/ko/
2024-11-17 05:00:01 -05:00
renovate[bot]
e83cef2b26 Update appleboy/ssh-action action to v1.2.0 2024-11-17 09:26:51 +01:00
renovate[bot]
b876c4b782 Update dependency org.jellyfin.sdk:jellyfin-core to v1.6.2 2024-11-17 09:26:15 +01:00
Niels van Velzen
300346feb8 Reset queue index on end
With this change the queue index will always reset to -1 (INDEX_NONE) once the last item in the queue ends instead of leaving the last queue item active. This solves various issues in the UI (like the screensaver) where it keeps showing the last item when nothing is playing
2024-11-16 20:26:34 +01:00
Niels van Velzen
ee1403aaad Fix audio now playing screen always showing "1 of x" instead of the actual queue position 2024-11-15 19:37:17 +01:00
renovate[bot]
a572710fd7 Update dependency io.coil-kt.coil3:coil-gif to v3.0.3 2024-11-15 19:33:38 +01:00
Niels van Velzen
003b4148cf Fix background of album in now playing screen when lyrics are displaying 2024-11-15 07:30:12 +01:00
newton181
52d844e281 Translated using Weblate (Spanish (Latin America))
Currently translated at 100.0% (535 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/es_419/
2024-11-14 15:00:01 -05:00
newton181
f7994d0e38 Translated using Weblate (Spanish)
Currently translated at 100.0% (535 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/es/
2024-11-14 15:00:00 -05:00
Niels van Velzen
ea33799059 Refactor subtitle profiles to be more readable 2024-11-14 20:06:14 +01:00
MichaelR416
487104fdf8 Really never load PGS externally 2024-11-14 19:21:40 +01:00
renovate[bot]
59b4bac902 Update CI dependencies 2024-11-14 19:20:23 +01:00
newton181
67467ec0ed Translated using Weblate (Spanish)
Currently translated at 100.0% (535 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/es/
2024-11-13 22:00:00 -05:00
nextlooper42
307bad3ecc Translated using Weblate (Slovak)
Currently translated at 100.0% (535 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/sk/
2024-11-12 21:00:00 -05:00
Martin
93227bd5b0 Translated using Weblate (Czech)
Currently translated at 99.2% (531 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/cs/
2024-11-12 15:00:00 -05:00
Niels van Velzen
6bb5055136 Fix player UI not hiding automatically 2024-11-12 16:44:30 +01:00
Niels van Velzen
3e059e5857 Never load PGS externally 2024-11-12 16:22:27 +01:00
Niels van Velzen
2ba2b70b85 Use relative index matching for subtitle selection 2024-11-12 15:59:39 +01:00
renovate[bot]
9732b3a232 Update dependency gradle to v8.11 2024-11-12 13:16:12 +01:00
renovate[bot]
af52c4d2d0 Update github/codeql-action action to v3.27.2 2024-11-12 13:15:06 +01:00
Niels van Velzen
169d094471 Fix skipping to end of video reports wrong end time 2024-11-12 09:02:39 +01:00
jh
87e4e7e0ee Translated using Weblate (Danish)
Currently translated at 100.0% (535 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/da/
2024-11-11 20:00:00 -05:00
Niels van Velzen
ae19a4c862 Fix subtitle menu not always showing "None" as first item 2024-11-11 21:51:17 +01:00
Niels van Velzen
f969f019d3 Fix ImageProvider always throwing 2024-11-11 21:51:07 +01:00
Roi Gabay
e7cbb2077b Translated using Weblate (Hebrew)
Currently translated at 100.0% (535 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/he/
2024-11-11 11:00:01 -05:00
Lucas
b64ed6a3b2 Translated using Weblate (French)
Currently translated at 100.0% (535 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/fr/
2024-11-11 11:00:00 -05:00
Simon Hung
9e508cca75 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (535 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/zh_Hant/
2024-11-11 08:00:00 -05:00
hoanghuy309
590b813b99 Translated using Weblate (Vietnamese)
Currently translated at 96.4% (516 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/vi/
2024-11-11 05:00:01 -05:00
Frederik Palmø
ed097cb2b9 Translated using Weblate (Danish)
Currently translated at 100.0% (535 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/da/
2024-11-11 05:00:01 -05:00
Simon Hung
2544af5c7d Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 97.5% (522 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/zh_Hant/
2024-11-11 05:00:00 -05:00
Blackspirits
5cf636c35d Translated using Weblate (Portuguese (Portugal))
Currently translated at 100.0% (535 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/pt_PT/
2024-11-11 05:00:00 -05:00
hyperjoco
5c6b798d86 Translated using Weblate (Hungarian)
Currently translated at 100.0% (535 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hu/
2024-11-11 02:44:55 -05:00
Blackspirits
06d3fd07d0 Translated using Weblate (Portuguese (Portugal))
Currently translated at 99.8% (534 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/pt_PT/
2024-11-11 02:44:55 -05:00
無情天
20484a21be Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (535 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/zh_Hans/
2024-11-11 00:00:00 -05:00
Paulo Henrique Garcia
69a5e5100f Translated using Weblate (Portuguese (Brazil))
Currently translated at 97.5% (522 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/pt_BR/
2024-11-10 14:00:01 -05:00
Kityn
d3063d7738 Translated using Weblate (Polish)
Currently translated at 100.0% (535 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/pl/
2024-11-10 11:00:00 -05:00
Bas
fa2a7bbca8 Translated using Weblate (Dutch)
Currently translated at 100.0% (535 of 535 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/nl/
2024-11-10 11:00:00 -05:00
Niels van Velzen
9acf4e3800 Upgrade to Coil 3 2024-11-10 16:51:11 +01:00
Niels van Velzen
91a27ee0ef Fix skipping to end of video reports wrong end time 2024-11-10 16:41:31 +01:00
kabel2
15554bd8f2 Add option to customize seek time (#3794)
* Add skip forward length setting

* Change default skipForwardLength to 30 seconds

* Add variable for skipForwardLength

* Fix merge

* Fix merge

* Try fix tests

* Fix test

* Fix seekbar preference with minimum value above 100

* Use long for forwardTime

---------

Co-authored-by: Niels van Velzen <git@ndat.nl>
2024-11-10 13:49:02 +00:00
renovate[bot]
17ed07c7f7 Update github/codeql-action action to v3.27.1 2024-11-10 14:28:20 +01:00
koreapyj
8f77b68346 Translated using Weblate (Korean)
Currently translated at 100.0% (4 of 4 strings)

Translation: Jellyfin Android/Jellyfin Android TV Fastlane
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv-fastlane/ko/
2024-11-10 07:00:00 -05:00
koreapyj
72163a48aa Translated using Weblate (Korean)
Currently translated at 99.4% (531 of 534 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/ko/
2024-11-10 07:00:00 -05:00
Kristian
53d982eff7 Translated using Weblate (Croatian)
Currently translated at 100.0% (534 of 534 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hr/
2024-11-09 18:00:00 -05:00
Anthony Magalhaes
5fb69dcb3c Translated using Weblate (Portuguese (Brazil))
Currently translated at 97.3% (520 of 534 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/pt_BR/
2024-11-08 17:00:00 -05:00
Roi Gabay
3109684eab Translated using Weblate (Hebrew)
Currently translated at 100.0% (534 of 534 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/he/
2024-11-08 12:00:00 -05:00
Zachary Arnaise
a98e131760 Translated using Weblate (French)
Currently translated at 99.6% (532 of 534 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/fr/
2024-11-08 12:00:00 -05:00
renovate[bot]
b74b63bb0a Update dependency com.android.tools:desugar_jdk_libs to v2.1.3 2024-11-07 13:05:26 +01:00
hoanghuy309
baf313c710 Translated using Weblate (Vietnamese)
Currently translated at 96.4% (515 of 534 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/vi/
2024-11-07 03:00:00 -05:00
無情天
5914d752b8 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (534 of 534 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/zh_Hans/
2024-11-06 17:00:00 -05:00
Weblate
28ffa78b8f Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/
2024-11-05 16:36:51 -05:00
Kityn
c49e7d289c Translated using Weblate (Polish)
Currently translated at 100.0% (539 of 539 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/pl/
2024-11-05 16:36:49 -05:00
Niels van Velzen
56d0064319 Use percentage seekbar for subtitle text size 2024-11-05 22:36:45 +01:00
Bas
18ff2a6ce3 Translated using Weblate (Dutch)
Currently translated at 100.0% (539 of 539 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/nl/
2024-11-05 14:00:00 -05:00
Niels van Velzen
77ef06de34 Implement TrickPlay images 2024-11-05 17:53:44 +01:00
Niels van Velzen
e1867b23e7 Catch errors when reporting playback state 2024-11-05 15:45:20 +01:00
Niels van Velzen
f21a9022d5 Fix position on playback stop reporting when skipping to playback end 2024-11-05 15:44:14 +01:00
Niels van Velzen
de883fb709 Fix sorting TV shows by last played 2024-11-05 15:44:06 +01:00
Niels van Velzen
f6e65fadac Catch errors when loading MediaSessionService artwork 2024-11-05 15:43:58 +01:00
Niels van Velzen
fc03e95d23 Remove runBlocking usage in HomeRowsFragment 2024-11-05 15:43:50 +01:00
Ayla Ounce
0d00adc8a6 Fix exo track id parsing for format change.
Without this changing to other audio tracks fails, it just keeps playing
the first one in the file. I was tipped off to the source of the problem
by lines like this in logcat:

11-04 19:26:01.244 11669 11669 D VideoManager: failed to parse track ID [0:2]

It appears exoplayer changed from a simple number for trackid to two
numbers separated by a colon. Haven't had any luck tracking down why,
but the latter of the two appears to match the old single number so,
just pull that out and use it.
2024-11-05 13:51:04 +01:00
Niels van Velzen
f474e2a753 Fix audio track selection when baking subtitles 2024-11-03 20:37:24 +01:00
Bas
ef236c8a55 Translated using Weblate (Dutch)
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/nl/
2024-11-03 14:06:00 -05:00
renovate[bot]
873f66c256 Update acra to v5.12.0 2024-11-03 18:46:29 +01:00
renovate[bot]
1e7c8bbf38 Update dependency org.jellyfin.sdk:jellyfin-core to v1.6.1 2024-11-03 18:45:55 +01:00
Niels van Velzen
0dae580732 Fix player crashing when playback failed 2024-11-03 18:42:59 +01:00
Niels van Velzen
98f84c630c Fix getSubtitleDeliveryMethod in StreamInfo always returning "Encoding" 2024-11-02 22:45:01 +01:00
Niels van Velzen
ef25757b45 Add additional information to PlaybackStartInfo 2024-11-02 22:44:52 +01:00
Niels van Velzen
ef4e2b56d7 Consider artist names before album artist names 2024-11-02 16:50:08 +01:00
Niels van Velzen
b0d5a788dc Consider artist images before album artist images 2024-11-02 16:02:38 +01:00
jh
2f14d77ed1 Translated using Weblate (Danish)
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/da/
2024-11-02 10:41:37 -04:00
Niels van Velzen
bc4f246b66 Don't close player with invalid state in onCreate 2024-11-02 10:35:42 +01:00
jh
3f96c3e7b9 Translated using Weblate (Danish)
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/da/
2024-11-01 21:41:37 -04:00
Bas
1939569320 Translated using Weblate (Dutch)
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/nl/
2024-11-01 16:41:37 -04:00
Niels van Velzen
0f97f5a95b Add missing audioCodec to music transcode 2024-10-31 23:01:29 +01:00
renovate[bot]
3657ad707e Update dependency com.android.tools.build:gradle to v8.7.2 2024-10-31 22:29:49 +01:00
Niels van Velzen
0c378dd7ff Fix synced lyrics not working in AudioNowPlayingFragment 2024-10-31 20:04:53 +01:00
Niels van Velzen
44ddb3b1b8 Decrease scaling size of active lyric line 2024-10-31 20:04:53 +01:00
Niels van Velzen
1bfc00a4d0 Fix moving focus down from home button in AudioNowPlayingFragment 2024-10-31 20:04:53 +01:00
x1Rz47
e41d1405ca Translated using Weblate (Uyghur)
Currently translated at 19.7% (106 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/ug/
2024-10-31 06:41:37 -04:00
newton181
69a4d13081 Translated using Weblate (Spanish (Latin America))
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/es_419/
2024-10-30 17:41:38 -04:00
newton181
26a73b9282 Translated using Weblate (Spanish)
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/es/
2024-10-30 17:41:37 -04:00
newton181
84f7a26017 Translated using Weblate (Spanish (Mexico))
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/es_MX/
2024-10-30 17:41:37 -04:00
renovate[bot]
b616a94287 Update dependency androidx.compose.ui:ui-tooling to v1.7.5 2024-10-30 21:19:14 +01:00
renovate[bot]
f6c5e6f467 Update dependency androidx.constraintlayout:constraintlayout to v2.2.0 2024-10-30 21:09:50 +01:00
renovate[bot]
a7f51b5a2c Update dependency androidx.compose.foundation:foundation to v1.7.5 2024-10-30 21:07:23 +01:00
renovate[bot]
322985c054 Update androidx.lifecycle to v2.8.7 2024-10-30 19:50:21 +01:00
renovate[bot]
0568361b6b Update androidx.fragment to v1.8.5 2024-10-30 19:48:35 +01:00
Niels van Velzen
b3beb8a92d Fix video restarting immediately when baking subtitles 2024-10-30 19:33:26 +01:00
Niels van Velzen
1062d7b844 Hide video player controls by default 2024-10-29 22:16:32 +01:00
Niels van Velzen
e7738c7bc1 Sync custom controls with built-in controls in leanback overlay 2024-10-29 21:30:35 +01:00
Niels van Velzen
e44b36e830 Fix adding music to existing queue 2024-10-29 18:41:39 +01:00
Niels van Velzen
2b4a5b6f2e Rewrite reporting helper with SDK 2024-10-29 16:40:35 +01:00
Niels van Velzen
67d470f961 Rewrite usages of QueueEntry in UI 2024-10-28 15:39:56 +01:00
Niels van Velzen
81aa372e40 Automatically disable subtitles when baking fails 2024-10-28 07:31:52 +01:00
Niels van Velzen
059140f7a2 Use "Ask to skip" by default for intros and outros 2024-10-27 22:17:17 +01:00
Frederik Palmø
cae32a2015 Translated using Weblate (Danish)
Currently translated at 99.8% (537 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/da/
2024-10-27 11:41:37 -04:00
NOV
3b3f5e2e42 Translated using Weblate (French)
Currently translated at 99.8% (537 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/fr/
2024-10-26 23:41:37 -04:00
queeup
8e1ce880a8 Translated using Weblate (Turkish)
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/tr/
2024-10-26 19:41:37 -04:00
renovate[bot]
f88dd54bcf Update dependency org.jellyfin.sdk:jellyfin-core to v1.6.0 2024-10-26 21:05:14 +02:00
Niels van Velzen
786110b52e Implement lyrics 2024-10-26 19:48:14 +02:00
renovate[bot]
7b51b848ca Update actions/setup-java action to v4.5.0 2024-10-26 16:59:28 +02:00
fract exe
829334a147 Translated using Weblate (Croatian)
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hr/
2024-10-25 10:41:37 -04:00
Rafael Morine
b128fa32b9 Translated using Weblate (Russian)
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/ru/
2024-10-25 06:41:37 -04:00
Ruben Teixeira
175d04d5ad Translated using Weblate (Portuguese (Portugal))
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/pt_PT/
2024-10-24 16:41:36 -04:00
renovate[bot]
4e67e51271 Update CI dependencies 2024-10-23 20:50:58 +02:00
fract exe
dabb514d17 Translated using Weblate (Croatian)
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hr/
2024-10-23 09:41:37 -04:00
fract exe
4d0a56aa68 Translated using Weblate (Croatian)
Currently translated at 93.1% (501 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hr/
2024-10-23 06:41:37 -04:00
John Doe
b49a09425f Translated using Weblate (Finnish)
Currently translated at 91.4% (492 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/fi/
2024-10-23 06:41:37 -04:00
Bas
0ac521096f Translated using Weblate (Dutch)
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/nl/
2024-10-22 17:41:36 -04:00
Niels van Velzen
8cdeb65345 Fix PlaybackController not always disabling default track 2024-10-20 22:44:25 +02:00
Niels van Velzen
fbfd681917 Fix not disabling embedded subtitle tracks that require baking 2024-10-20 15:04:18 +02:00
Niels van Velzen
9ea1cc58a7 Always use lowercase codec when mapping to mime type 2024-10-20 15:04:18 +02:00
Niels van Velzen
662b1825c1 Rewrite all subtitle behavior 2024-10-20 15:04:18 +02:00
AfmanS
0cd34ba163 Translated using Weblate (Portuguese (Portugal))
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/pt_PT/
2024-10-19 14:41:36 -04:00
Bas
24120146ec Translated using Weblate (Dutch)
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/nl/
2024-10-19 06:41:36 -04:00
Niels van Velzen
d96e23d001 Change upcomingMinimumServerVersion to 10.10.0 to warn about upcoming 10.10 requirement 2024-10-17 18:55:26 +02:00
renovate[bot]
354acf5bf5 Update dependency androidx.compose.ui:ui-tooling to v1.7.4 2024-10-16 22:10:40 +02:00
renovate[bot]
7f480ea7fc Update dependency androidx.activity:activity to v1.9.3 2024-10-16 21:34:33 +02:00
renovate[bot]
e04824ce5e Update dependency androidx.compose.foundation:foundation to v1.7.4 2024-10-16 21:34:07 +02:00
Roi Gabay
d6d48ca78a Translated using Weblate (Hebrew)
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/he/
2024-10-16 05:44:46 -04:00
Niels van Velzen
016de2d087 Update package for Koin viewModel import
Old one is deprecated
2024-10-15 22:46:25 +02:00
renovate[bot]
c1e8653da8 Update dependency com.android.tools.build:gradle to v8.7.1 2024-10-15 22:16:09 +02:00
Niels van Velzen
746c662bb9 Use parcelable array list in DestinationFragmentView saved state 2024-10-15 22:15:16 +02:00
renovate[bot]
79f6bc9683 Update github/codeql-action action to v3.26.13 2024-10-15 22:15:06 +02:00
Niels van Velzen
acf8af6441 Use Java 21 in jvm toolchain 2024-10-15 17:54:32 +02:00
myrad2267
b4e4029c86 Translated using Weblate (French)
Currently translated at 99.0% (533 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/fr/
2024-10-15 00:41:36 -04:00
Persephone42
957f3643bf Translated using Weblate (Serbian)
Currently translated at 59.4% (320 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/sr/
2024-10-14 15:25:55 -04:00
Ilias Theodoopoulos
f97dd87984 Translated using Weblate (Greek)
Currently translated at 92.7% (499 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/el/
2024-10-14 10:41:36 -04:00
Niels van Velzen
cade3fd6bf Fix skipping to next episode 2024-10-13 16:56:56 +02:00
hyperjoco
19dc038087 Translated using Weblate (Hungarian)
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hu/
2024-10-13 09:41:36 -04:00
無情天
1957185e18 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/zh_Hans/
2024-10-13 02:41:36 -04:00
nextlooper42
73384b6b9a Translated using Weblate (Slovak)
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/sk/
2024-10-12 21:41:36 -04:00
Justin
4ba02728c2 Translated using Weblate (German)
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/de/
2024-10-12 21:41:36 -04:00
Hoomaane79
8902e63a31 Translated using Weblate (Persian)
Currently translated at 100.0% (4 of 4 strings)

Translation: Jellyfin Android/Jellyfin Android TV Fastlane
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv-fastlane/fa/
2024-10-12 16:41:36 -04:00
hoanghuy309
288c01befb Translated using Weblate (Vietnamese)
Currently translated at 96.4% (519 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/vi/
2024-10-12 13:41:36 -04:00
Andi Chandler
f7ff9f768a Translated using Weblate (English (United Kingdom))
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/en_GB/
2024-10-12 13:41:36 -04:00
Niels van Velzen
bec60ed4eb Remove DeviceUtils 2024-10-12 15:25:36 +02:00
Kityn
c509955f21 Translated using Weblate (Polish)
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/pl/
2024-10-12 07:41:36 -04:00
Bas
26f43ced62 Translated using Weblate (Dutch)
Currently translated at 100.0% (538 of 538 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/nl/
2024-10-12 07:41:36 -04:00
JagerSprinkles
24acb71eee Add preference for default zoom mode (#4020)
* Add logic for default zoom mode setting

* Update to use enumPreference.

* Use MenuBuilder for the zoom mode menu in the player.
Update string for zoom mode preference to aspect ratio.
Update variable name for playerZoomMode in UserPreferences.kt

* Add NonNull as suggested by bot.
Removed unused line of code.

* Add NonNull as suggested by bot

* update string resource for default video zoom

* Misc fixes and refactoring

---------

Co-authored-by: Niels van Velzen <git@ndat.nl>
2024-10-12 11:33:29 +02:00
Kityn
e1db0523ee Translated using Weblate (Polish)
Currently translated at 100.0% (537 of 537 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/pl/
2024-10-12 04:41:36 -04:00
nextlooper42
84d7639f3e Translated using Weblate (Slovak)
Currently translated at 100.0% (537 of 537 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/sk/
2024-10-11 23:41:36 -04:00
Bas
8e13a51b68 Translated using Weblate (Dutch)
Currently translated at 100.0% (537 of 537 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/nl/
2024-10-11 19:41:36 -04:00
Niels van Velzen
bf4198a8b8 Restore forced aspect ratio for poster types on video items 2024-10-11 21:45:50 +02:00
renovate[bot]
dd4bafd5a4 Update dependency io.mockk:mockk to v1.13.13 2024-10-11 21:45:40 +02:00
renovate[bot]
2d9cdb7d6f Update actions/upload-artifact action to v4.4.3 2024-10-11 21:45:32 +02:00
renovate[bot]
f458d3fdf3 Update Kotlin to v2.0.21 2024-10-11 21:44:41 +02:00
Niels van Velzen
0537a2031e Add "ask to skip" media segment action 2024-10-11 21:33:33 +02:00
Bas
4f23ad47b6 Translated using Weblate (Dutch)
Currently translated at 100.0% (536 of 536 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/nl/
2024-10-10 17:41:36 -04:00
trawzified
dede52b978 Translated using Weblate (Dutch)
Currently translated at 100.0% (536 of 536 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/nl/
2024-10-10 15:38:44 -04:00
BromTeque
8fe6d807e3 Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (536 of 536 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/nb_NO/
2024-10-10 09:41:36 -04:00
newton181
90cc4dc658 Translated using Weblate (Spanish (Latin America))
Currently translated at 100.0% (536 of 536 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/es_419/
2024-10-09 17:41:36 -04:00
Matheo
8acd8c1e69 Translated using Weblate (French)
Currently translated at 97.7% (524 of 536 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/fr/
2024-10-09 17:41:36 -04:00
newton181
8574947aa1 Translated using Weblate (Spanish)
Currently translated at 100.0% (536 of 536 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/es/
2024-10-09 17:41:36 -04:00
newton181
85bbb5daaa Translated using Weblate (Spanish (Mexico))
Currently translated at 100.0% (536 of 536 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/es_MX/
2024-10-09 17:41:36 -04:00
Niels van Velzen
961c858d55 Don't skip segments shorter than 1 second 2024-10-09 21:36:14 +02:00
無情天
ea397196a7 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (536 of 536 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/zh_Hans/
2024-10-09 08:41:35 -04:00
Roi Gabay
fbcb96f051 Translated using Weblate (Hebrew)
Currently translated at 100.0% (536 of 536 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/he/
2024-10-09 03:41:36 -04:00
hoanghuy309
b9d57641d2 Translated using Weblate (Vietnamese)
Currently translated at 96.4% (517 of 536 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/vi/
2024-10-08 08:41:36 -04:00
Andi Chandler
10b0d33fc7 Translated using Weblate (English (United Kingdom))
Currently translated at 100.0% (536 of 536 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/en_GB/
2024-10-08 08:41:35 -04:00
Niels van Velzen
c522f09cc2 Fix video player not closing when fragment is stopped 2024-10-08 11:59:17 +02:00
Kityn
095721e021 Translated using Weblate (Polish)
Currently translated at 100.0% (536 of 536 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/pl/
2024-10-08 04:41:35 -04:00
nextlooper42
d90827c524 Translated using Weblate (Slovak)
Currently translated at 100.0% (536 of 536 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/sk/
2024-10-07 21:41:35 -04:00
Bas
1ac4d49e2b Translated using Weblate (Dutch)
Currently translated at 100.0% (536 of 536 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/nl/
2024-10-07 21:41:35 -04:00
renovate[bot]
ef5b764ae2 Update CI dependencies 2024-10-07 22:41:39 +02:00
Niels van Velzen
e43492ffa5 Implement skip action 2024-10-07 22:41:08 +02:00
Niels van Velzen
8096632a79 Add preferences for media segments 2024-10-07 22:41:08 +02:00
Niels van Velzen
cd1623c6f3 Fix Detekt max line length not matching Editorconfig 2024-10-07 22:34:13 +02:00
hyperjoco
50ad7b30b9 Translated using Weblate (Hungarian)
Currently translated at 100.0% (527 of 527 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hu/
2024-10-07 05:41:35 -04:00
hoanghuy309
505cdcd723 Translated using Weblate (Vietnamese)
Currently translated at 96.3% (508 of 527 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/vi/
2024-10-07 01:41:35 -04:00
stanol
025a55ca08 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (527 of 527 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/uk/
2024-10-06 10:41:35 -04:00
Andi Chandler
2e9182a868 Translated using Weblate (English (United Kingdom))
Currently translated at 100.0% (527 of 527 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/en_GB/
2024-10-06 10:41:35 -04:00
Niels van Velzen
64924719ba Remove nullability checks for BaseItemDtoQueryResult.items 2024-10-05 14:51:00 +02:00
Niels van Velzen
dc9f36285c Update "new premieres" to use new indexNumber filter in API request 2024-10-05 14:51:00 +02:00
Niels van Velzen
64b7fb1e79 Add missing properties in ModelCompat 2024-10-05 14:51:00 +02:00
Niels van Velzen
50848441ac Update Jellyfin SDK to 10.6.0-beta.1 2024-10-05 14:51:00 +02:00
nextlooper42
939ec5a1c0 Translated using Weblate (Slovak)
Currently translated at 100.0% (527 of 527 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/sk/
2024-10-05 06:41:35 -04:00
皇甫朝云
187bc9c5d1 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (527 of 527 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/zh_Hans/
2024-10-05 06:41:35 -04:00
Kityn
9478600764 Translated using Weblate (Polish)
Currently translated at 100.0% (527 of 527 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/pl/
2024-10-04 19:41:35 -04:00
Bas
70fbbcd9db Translated using Weblate (Dutch)
Currently translated at 100.0% (527 of 527 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/nl/
2024-10-04 19:41:35 -04:00
JagerSprinkles
f4401578de Add preference to delay start of video (#4025)
* Add setting to adjust video player start delay

* Update PlaybackController to skip video start delay if set to zero.
Update default for video start delay.
Update video start delay string.

* Update mVideoManager null check for delayed start logic.

* Final changes

---------

Co-authored-by: Niels van Velzen <git@ndat.nl>
2024-10-04 20:53:03 +00:00
CPK
ef4082cb38 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (526 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/zh_Hans/
2024-10-03 09:41:35 -04:00
Yogesh
c6ed49c0ac Translated using Weblate (Hindi)
Currently translated at 57.6% (303 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-10-03 01:41:35 -04:00
renovate[bot]
7955cbddb7 Update dependency androidx.compose.ui:ui-tooling to v1.7.3 2024-10-03 07:28:11 +02:00
renovate[bot]
cbda5142e8 Update androidx.fragment to v1.8.4 2024-10-02 21:27:48 +02:00
renovate[bot]
b02b7925da Update dependency androidx.compose.foundation:foundation to v1.7.3 2024-10-02 21:27:36 +02:00
hoanghuy309
eb4389f32f Translated using Weblate (Vietnamese)
Currently translated at 96.3% (507 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/vi/
2024-10-02 02:41:35 -04:00
Niels van Velzen
c993be8450 Rewrite NowPlayingView in compose 2024-09-30 16:13:31 +02:00
michael
0cfffdbe3d VLC long position 2024-09-29 10:01:44 +02:00
Niels van Velzen
d5bb7327f7 Add flow support for elements 2024-09-28 23:19:13 +02:00
Niels van Velzen
56eb00197c Fix queue not advancing 2024-09-28 18:33:14 +02:00
renovate[bot]
b219f46994 Update dependency ubuntu to v24 2024-09-27 21:39:52 +02:00
renovate[bot]
241139d5cf Update CI dependencies 2024-09-27 21:39:18 +02:00
Niels van Velzen
2c7bd27525 Fix incorrectly navigating on resume 2024-09-27 21:23:44 +02:00
BromTeque
30fcb192da Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (526 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/nb_NO/
2024-09-26 17:41:35 -04:00
Niels van Velzen
fdde857be6 Change upcomingMinimumServerVersion to 10.9.11 to encourage updating
Profile related fixes in 10.9.10 improve playback, however users may not be aware of that. Use the upcoming minimum server function to notify users about this version. We'll still support 10.9.0>=.
2024-09-26 20:47:23 +02:00
hyperjoco
a607cb4a83 Translated using Weblate (Hungarian)
Currently translated at 99.8% (525 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hu/
2024-09-26 12:41:35 -04:00
renovate[bot]
70654a2720 Update dependency gradle to v8.10.2 2024-09-24 09:01:43 +02:00
utmrxanpq
0c5eda9572 Translated using Weblate (Kannada)
Currently translated at 4.7% (25 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/kn/
2024-09-23 19:41:35 -04:00
utmrxanpq
3e944fa2bc Translated using Weblate (Kannada)
Currently translated at 3.8% (20 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/kn/
2024-09-23 17:28:33 -04:00
Bond_009
90573f610f Fix aspect ratio specials
Don't overwrite the aspect ratio for videos
Change return value of ImageHelper.getImageAspectRatio to not be nullable

Fixes #1192
Possibly a regression from #830
2024-09-23 12:40:25 +02:00
hoanghuy309
64ffa02250 Translated using Weblate (Vietnamese)
Currently translated at 96.0% (505 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/vi/
2024-09-22 16:04:43 -04:00
Yogesh
8d5414ce5c Translated using Weblate (Hindi)
Currently translated at 57.2% (301 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-09-22 05:41:36 -04:00
aspectxDev
ecc5d47146 Translated using Weblate (Indonesian)
Currently translated at 75.2% (396 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/id/
2024-09-22 02:41:35 -04:00
renovate[bot]
c5ce43c95b Update koin to v4 2024-09-21 22:37:50 +02:00
renovate[bot]
a3e8482d3e Update github/codeql-action action to v3.26.8 2024-09-21 22:37:42 +02:00
l00d3r
d3834f5e73 Translated using Weblate (Estonian)
Currently translated at 100.0% (526 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/et/
2024-09-21 10:41:35 -04:00
Niels van Velzen
579186d661 Implement My media (small) home section 2024-09-21 16:38:41 +02:00
Niels van Velzen
7b61de171d Increase amount of home sections from 7 to 10 2024-09-21 15:54:52 +02:00
renovate[bot]
fe66a61ce0 Update dependency org.jetbrains.kotlinx:kotlinx-serialization-json to v1.7.3 2024-09-19 21:59:09 +02:00
renovate[bot]
208e7c4ee1 Update dependency androidx.compose.foundation:foundation to v1.7.2 2024-09-19 08:54:45 +02:00
renovate[bot]
a3965fcce6 Update dependency com.android.tools.build:gradle to v8.6.1 2024-09-19 08:53:04 +02:00
renovate[bot]
249a3f6bbe Update androidx.lifecycle to v2.8.6 2024-09-19 08:50:55 +02:00
renovate[bot]
50eee051c3 Update dependency androidx.compose.ui:ui-tooling to v1.7.2 2024-09-19 08:50:34 +02:00
renovate[bot]
03a0001770 Update dependency androidx.startup:startup-runtime to v1.2.0 2024-09-19 08:50:21 +02:00
Niels van Velzen
933fdbcca4 Fix back press callback in CustomPlaybackOverlayFragment 2024-09-17 14:16:10 +02:00
sand14
9c6c9c4315 Translated using Weblate (Romanian)
Currently translated at 91.0% (479 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/ro/
2024-09-14 22:41:34 -04:00
renovate[bot]
949c7e7198 Update CI dependencies 2024-09-14 10:56:01 +02:00
renovate[bot]
2efe6622b9 Update Kotlin to v1.9.0 2024-09-14 10:55:53 +02:00
renovate[bot]
1281785513 Update acra to v5.11.4 2024-09-14 10:55:40 +02:00
Ruben Teixeira
bcc9462514 Translated using Weblate (Portuguese (Portugal))
Currently translated at 100.0% (526 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/pt_PT/
2024-09-12 17:41:34 -04:00
nextlooper42
6d3b27ebac Translated using Weblate (Slovak)
Currently translated at 100.0% (526 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/sk/
2024-09-11 07:41:34 -04:00
queeup
f4df2fd1d7 Translated using Weblate (Turkish)
Currently translated at 100.0% (526 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/tr/
2024-09-10 17:41:34 -04:00
renovate[bot]
0dae1535b2 Update dependency androidx.compose.ui:ui-tooling to v1.7.1 2024-09-10 22:08:25 +02:00
renovate[bot]
a6a34ffff0 Update dependency androidx.compose.foundation:foundation to v1.7.1 2024-09-10 22:04:43 +02:00
renovate[bot]
14d67e446d Update dependency gradle to v8.10.1 2024-09-10 15:01:02 +02:00
Pascal Weidenhammer
51cedee9b3 Upgrade ConnectHelpAlertFragment to Jetpack Compose (#3596)
* Add AndroidX TV Material implementation

* Upgrade ConnectHelpAlertFragment to Jetpack Compose

* Add Jellyfin blue Material Theme

* Add Material Theme and remove back button from ConnectHelpAlert

There should be no onscreen back buttons, because the tv remote has a dedicated back button: https://developer.android.com/design/ui/tv/guides/foundations/navigation-on-tv#dont-display-a-back-button

* Add "Got it" button back to ConnectHelp

* Remove duplicate tv-material library inclusion

* Revert "Add Jellyfin blue Material Theme"

This reverts commit 1c16039208.

* Remove custom theme in ConnectHelp

* Hardcode colors in ConnectHelpAlert

* Refactor ConnectHelpAlertFragment

---------

Co-authored-by: Niels van Velzen <git@ndat.nl>
2024-09-10 12:59:51 +00:00
renovate[bot]
715a15a5ba Update actions/setup-java action to v4.3.0 2024-09-10 14:59:42 +02:00
renovate[bot]
9ecea3088f Update dependency io.gitlab.arturbosch.detekt to v1.23.7 2024-09-10 14:59:31 +02:00
Niels van Velzen
a87ea6c060 Avoid baking subtitles when transcoding 2024-09-10 14:48:23 +02:00
MichaelRUSF
706b64573a Add resolution codec check (#3953)
* Add 4k resolution codec check

* 4k profile check

* Apply review changes

* Remove hard-coded resolutions
2024-09-08 11:33:16 +02:00
renovate[bot]
cd8e619ddc Update dependency org.jetbrains.kotlinx:kotlinx-serialization-json to v1.7.2 2024-09-08 10:52:02 +02:00
renovate[bot]
8d6d3b3a35 Update dependency org.jellyfin.sdk:jellyfin-core to v1.5.5 2024-09-08 10:51:51 +02:00
bene toffix
25354395ed Translated using Weblate (Catalan)
Currently translated at 100.0% (526 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/ca/
2024-09-07 12:41:34 -04:00
Hoomaane79
3a6b619aa4 Translated using Weblate (Persian)
Currently translated at 100.0% (4 of 4 strings)

Translation: Jellyfin Android/Jellyfin Android TV Fastlane
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv-fastlane/fa/
2024-09-05 19:41:34 -04:00
Andreas Hantschel
309d2dc532 Translated using Weblate (German)
Currently translated at 99.8% (525 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/de/
2024-09-05 15:41:34 -04:00
renovate[bot]
1d0e2b0f66 Update dependency androidx.compose.ui:ui-tooling to v1.7.0 2024-09-05 16:19:02 +02:00
renovate[bot]
0557df1faa Update dependency androidx.compose.foundation:foundation to v1.7.0 2024-09-05 16:13:01 +02:00
renovate[bot]
7684efbf0b Update dependency com.android.tools:desugar_jdk_libs to v2.1.2 2024-09-05 09:06:45 +02:00
renovate[bot]
a4e14dbf45 Update dependency androidx.activity:activity to v1.9.2 2024-09-05 09:04:23 +02:00
renovate[bot]
32c3b80008 Update androidx.fragment to v1.8.3 2024-09-05 09:04:03 +02:00
renovate[bot]
05249719b4 Update androidx.lifecycle to v2.8.5 2024-09-05 09:03:48 +02:00
Fabian T
987bda9918 Translated using Weblate (German)
Currently translated at 99.4% (523 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/de/
2024-09-03 19:41:33 -04:00
Niels van Velzen
13f6a70fa5 Fix current fragment not removed on back press 2024-09-03 21:37:08 +02:00
Niels van Velzen
e460c6e403 Use custom logic for fragment back stack 2024-09-03 16:38:23 +02:00
MichaelRUSF
86d6195a06 Update DeviceUtils with Xiaomi TV Stick (#3951)
* Add Mi Stick to DeviceUtils

* Fix comment
2024-09-03 13:54:20 +02:00
renovate[bot]
c1df5a69aa Update CI dependencies 2024-09-02 13:22:19 +02:00
renovate[bot]
ea4289b1ef Update dependency com.android.tools.build:gradle to v8.6.0 2024-09-02 13:21:48 +02:00
Cielo Gaona
7357694c8e Translated using Weblate (Spanish (Mexico))
Currently translated at 99.6% (524 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/es_MX/
2024-09-01 02:41:34 -04:00
Rattapoom Suaydee
1d79a8bafe Translated using Weblate (Thai)
Currently translated at 40.3% (212 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/th/
2024-08-31 14:41:33 -04:00
Simon Hung
dd450c607d Translated using Weblate (Chinese (Traditional))
Currently translated at 99.0% (521 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/zh_Hant/
2024-08-30 10:41:33 -04:00
millallo
d64c35c6f9 Translated using Weblate (Italian)
Currently translated at 99.8% (525 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/it/
2024-08-29 08:41:33 -04:00
無情天
f521abf7bd Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (526 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/zh_Hans/
2024-08-28 20:41:33 -04:00
renovate[bot]
144e501bd2 Update dependency com.android.tools:desugar_jdk_libs to v2.1.1 2024-08-28 22:10:19 +02:00
stanol
80f2de16b1 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (526 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/uk/
2024-08-28 09:41:33 -04:00
Kityn
13f95d5f60 Translated using Weblate (Polish)
Currently translated at 100.0% (526 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/pl/
2024-08-28 03:41:33 -04:00
Bas
990c8d4ad8 Translated using Weblate (Dutch)
Currently translated at 100.0% (526 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/nl/
2024-08-27 10:41:33 -04:00
Andi Chandler
b7753deb2f Translated using Weblate (English (United Kingdom))
Currently translated at 100.0% (526 of 526 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/en_GB/
2024-08-27 10:41:33 -04:00
Niels van Velzen
0c88593d30 Tidy up 2024-08-27 13:50:53 +02:00
rickysixx
a304403fc0 Enable ExoPlayer debugging logs if debug mode is active 2024-08-27 13:50:53 +02:00
rickysixx
1235eb1ff5 Add developer preference to prefer extension renderers to core ones 2024-08-27 13:50:53 +02:00
renovate[bot]
2b1143a362 Update gradle/actions action to v4.0.1 2024-08-27 13:33:40 +02:00
renovate[bot]
be43311978 Update androidx.media3 to v1.4.1 2024-08-27 13:33:25 +02:00
renovate[bot]
3916c2a117 Update dependency org.jellyfin.sdk:jellyfin-core to v1.5.4 2024-08-27 13:29:25 +02:00
renovate[bot]
7c9dd0001e Update dependency com.android.tools:desugar_jdk_libs to v2.1.0 2024-08-26 18:50:47 +02:00
Niels van Velzen
b116db0d49 Fix capitalization in video profiles causing invalid transcodes 2024-08-25 22:28:02 +02:00
renovate[bot]
b362ac42ff Update aboutlibraries to v11.2.3 2024-08-25 18:48:38 +02:00
renovate[bot]
de2a27052e Update github/codeql-action action to v3.26.5 2024-08-25 18:48:15 +02:00
MichaelRUSF
fe05b82b95 Fix for devices with no HEVC Main 10 support 2024-08-25 18:47:35 +02:00
MichaelRUSF
50ef4a7718 AVC level check profiles 2024-08-25 11:55:24 +02:00
MichaelRUSF
49bcd6fe2c AVC media codec levels 2024-08-25 11:55:24 +02:00
Eric Leblanc
c03bec878b more granular seek setting 2024-08-24 17:58:29 +02:00
renovate[bot]
8cca27238f Update github/codeql-action action to v3.26.4 2024-08-22 17:35:10 +02:00
renovate[bot]
09027c2bc5 Update Kotlin to v2.0.20 2024-08-22 17:34:53 +02:00
無情天
7297086045 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (524 of 524 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/zh_Hans/
2024-08-21 16:25:07 -04:00
renovate[bot]
f2a47d1c9a Update dependency androidx.tv:tv-material to v1.0.0 2024-08-21 20:48:38 +02:00
Niels van Velzen
1f789ac00f Improve behavior when no playable items found 2024-08-20 16:44:06 +02:00
Niels van Velzen
1199fa5253 Disable direct play when encountering playback error twice 2024-08-20 16:43:47 +02:00
renovate[bot]
a9ff992926 Update github/codeql-action action to v3.26.3 2024-08-20 13:04:24 +02:00
Niels van Velzen
a4ec607543 Fix toggling favorites in ItemListFragment 2024-08-18 17:18:32 +02:00
renovate[bot]
299751156d Update dependency com.android.tools.build:gradle to v8.5.2 2024-08-18 10:19:41 +02:00
renovate[bot]
f8eb6468d4 Update CI dependencies 2024-08-18 10:19:32 +02:00
itoudium
77cc5902bb Translated using Weblate (Japanese)
Currently translated at 70.8% (371 of 524 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/ja/
2024-08-17 02:41:32 -04:00
bene toffix
a143dcbec9 Translated using Weblate (Catalan)
Currently translated at 100.0% (524 of 524 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/ca/
2024-08-16 18:41:32 -04:00
Andi Chandler
c1c144f813 Translated using Weblate (English (United Kingdom))
Currently translated at 100.0% (524 of 524 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/en_GB/
2024-08-16 14:41:32 -04:00
Niels van Velzen
ae2d74b263 Fix channels interpreted as Live TV channels 2024-08-16 20:29:01 +02:00
renovate[bot]
c025b62f85 Update dependency gradle to v8.10 2024-08-16 08:44:59 +02:00
Mjöllnir
5f181b9ded Translated using Weblate (French)
Currently translated at 100.0% (524 of 524 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/fr/
2024-08-15 21:41:32 -04:00
Niels van Velzen
ff52e2a9a7 Use device profile builder in jellyfinPlugin 2024-08-13 17:38:24 +02:00
Niels van Velzen
e65492c254 Enable constant bitrate seeking 2024-08-13 16:33:26 +02:00
elokoidu
9ac58e22fb Translated using Weblate (Estonian)
Currently translated at 100.0% (524 of 524 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/et/
2024-08-13 09:41:32 -04:00
millallo
cc080fe93c Translated using Weblate (Italian)
Currently translated at 99.8% (523 of 524 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/it/
2024-08-12 17:41:32 -04:00
VOLO Digital Agency
2e3e8f258c Translated using Weblate (Swedish)
Currently translated at 100.0% (524 of 524 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/sv/
2024-08-12 03:41:32 -04:00
Niels van Velzen
9577e11e41 Fix crash when item doesn't have a default audio/subtitle stream 2024-08-12 08:30:40 +02:00
Niels van Velzen
6111cbec5a Re-use supportsHevc in one additional place 2024-08-10 22:06:33 +02:00
Joseph Price
c7a4c89b74 Really don't request HEVC if unsupported 2024-08-10 22:06:33 +02:00
stanol
35300e605b Translated using Weblate (Ukrainian)
Currently translated at 100.0% (524 of 524 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/uk/
2024-08-10 08:41:32 -04:00
Niels van Velzen
b0cd0c5b37 Fix InfoRow not using requested media source 2024-08-10 08:24:06 +02:00
Niels van Velzen
aa5fb63b71 Reset display mode when exiting video player 2024-08-09 22:12:19 +02:00
Niels van Velzen
d661583a86 Improve InfoRow colors/font readability 2024-08-09 22:12:13 +02:00
Niels van Velzen
fe31c19f15 Fix intros showing next up screen 2024-08-09 20:37:40 +02:00
Niels van Velzen
fcd9751d82 Fix crash when starting next episode 2024-08-09 19:57:53 +02:00
Niels van Velzen
f8c5345871 Graduate reactive homepage to stable 2024-08-08 22:28:59 +02:00
Sina Faghihi
213fa1fcf8 Translated using Weblate (Persian)
Currently translated at 70.0% (367 of 524 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/fa/
2024-08-08 10:41:31 -04:00
無情天
157352cf3a Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (524 of 524 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/zh_Hans/
2024-08-08 06:41:31 -04:00
renovate[bot]
b5c5478c76 Update gradle/actions action to v4 2024-08-07 22:16:27 +02:00
renovate[bot]
0385f63fbe Update dependency androidx.tv:tv-material to v1.0.0-rc02 2024-08-07 22:09:41 +02:00
renovate[bot]
6cc1031b8a Update dependency androidx.work:work-runtime to v2.9.1 2024-08-07 22:09:33 +02:00
Vladimir Apraksin
3269a5283d Translated using Weblate (Russian)
Currently translated at 95.6% (501 of 524 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/ru/
2024-08-07 14:32:34 -04:00
renovate[bot]
587f0fb35a Update dependency org.jellyfin.sdk:jellyfin-core to v1.5.3 2024-08-07 12:55:12 +02:00
moi15moi
1f7c6cc4d3 Translated using Weblate (French)
Currently translated at 99.8% (523 of 524 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/fr/
2024-08-06 18:41:31 -04:00
queeup
e307b3baa0 Translated using Weblate (Turkish)
Currently translated at 100.0% (524 of 524 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/tr/
2024-08-06 13:41:31 -04:00
Justin
a8cb3a595c Translated using Weblate (German)
Currently translated at 100.0% (524 of 524 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/de/
2024-08-06 13:41:31 -04:00
Niels van Velzen
044ce67110 Fix SelectQualityAction crashing when opening menu 2024-08-06 15:14:39 +02:00
Niels van Velzen
f6c536880d Use "direct play" as default audio behavior 2024-08-06 15:14:31 +02:00
renovate[bot]
409b34238a Update Kotlin to v2.0.10 2024-08-06 14:51:05 +02:00
Kityn
6ca4b4679c Translated using Weblate (Polish)
Currently translated at 100.0% (524 of 524 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/pl/
2024-08-06 03:41:31 -04:00
hoanghuy309
5bb74f689a Translated using Weblate (Vietnamese)
Currently translated at 96.1% (504 of 524 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/vi/
2024-08-05 23:41:31 -04:00
queeup
daa4188887 Translated using Weblate (Turkish)
Currently translated at 100.0% (524 of 524 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/tr/
2024-08-05 20:41:31 -04:00
Marc Vollmer
3b852a031e Fix NullPointerException in LeanbackOverlayFragment.onPause 2024-08-05 21:37:42 +02:00
Bas
7aa4d2e5fa Translated using Weblate (Dutch)
Currently translated at 100.0% (524 of 524 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/nl/
2024-08-05 14:41:31 -04:00
Niels van Velzen
8c3be44664 Fix RewriteMediaManager emitting onQueueStatusChanged with a queue when there is no audio items available 2024-08-05 20:12:40 +02:00
Niels van Velzen
7bc99d2e66 Fix NullPointerException in CustomPlaybackOverlayFragment.onResume 2024-08-05 19:08:41 +02:00
Niels van Velzen
9ecf952351 Remove context requirement from ExoPlayerProfile 2024-08-05 19:00:41 +02:00
Weblate
2e78a483aa Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/
2024-08-05 12:33:51 -04:00
Niels van Velzen
e8a81799e2 Add new subtitle customization preferences 2024-08-05 18:33:47 +02:00
Niels van Velzen
be06693978 Remove custom subtitle renderer 2024-08-05 18:33:47 +02:00
Niels van Velzen
0f38bd1691 Rewrite queuing 2024-08-05 18:33:30 +02:00
majmongoose
a8c9f054ab Add Google Chromecast HD limitation (#3820)
* Add identification for Google Chromecast HD

Possibly an upstream bug is preventing this from working, as the chromecast is being idenitified correctly in debugging.

* Update DeviceUtils.kt

Added a note for the reason of the change.

* Removed unintentional imports

* Update app/src/main/java/org/jellyfin/androidtv/util/DeviceUtils.kt

Make changes suggested by maintainer.

Co-authored-by: Niels van Velzen <nielsvanvelzen@users.noreply.github.com>

* Update DeviceUtils.kt

Removed unneeded isChromecastHD

---------

Co-authored-by: Niels van Velzen <nielsvanvelzen@users.noreply.github.com>
2024-08-05 15:43:30 +00:00
renovate[bot]
4f3c6b331d Update actions/setup-java action to v4.2.2 2024-08-05 17:42:28 +02:00
Niels van Velzen
61b4178dec Fix AudioNowPlayingFragment showing wrong item in extremely rare cases 2024-08-04 22:03:00 +02:00
Niels van Velzen
9aa1759ae7 Fix race condition in PictureViewerViewModel
Using the showNext/showPrevious functions while the album is still loading can cause an IndexOutOfBoundsException. Check if the album is empty (default value) first before trying to read from it to avoid these crashes.
2024-08-04 22:02:53 +02:00
Niels van Velzen
2e55987939 Strip HTML from descriptions in leanback integration 2024-08-04 22:02:42 +02:00
dharmik2319
d3f3b38c31 Translated using Weblate (Hindi)
Currently translated at 27.1% (141 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 08:15:01 -04:00
dharmik2319
f8cf1f48a3 Translated using Weblate (Hindi)
Currently translated at 26.9% (140 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 08:14:28 -04:00
dharmik2319
691a8f8ac5 Translated using Weblate (Hindi)
Currently translated at 26.5% (138 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 08:13:55 -04:00
dharmik2319
af6df123d2 Translated using Weblate (Hindi)
Currently translated at 26.3% (137 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 08:12:48 -04:00
dharmik2319
dad6570d28 Translated using Weblate (Hindi)
Currently translated at 18.4% (96 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 07:18:33 -04:00
dharmik2319
8abfc9ebd2 Translated using Weblate (Hindi)
Currently translated at 18.3% (95 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 07:17:59 -04:00
dharmik2319
8aabf8dc9a Translated using Weblate (Hindi)
Currently translated at 18.1% (94 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 07:16:20 -04:00
dharmik2319
31abdc6060 Translated using Weblate (Hindi)
Currently translated at 17.9% (93 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 07:15:47 -04:00
dharmik2319
954491718e Translated using Weblate (Hindi)
Currently translated at 17.7% (92 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 07:15:14 -04:00
dharmik2319
bb550630dd Translated using Weblate (Hindi)
Currently translated at 17.5% (91 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 07:14:41 -04:00
dharmik2319
1007e22d97 Translated using Weblate (Hindi)
Currently translated at 17.3% (90 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 07:14:08 -04:00
dharmik2319
ec6161bc97 Translated using Weblate (Hindi)
Currently translated at 17.1% (89 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 07:13:35 -04:00
dharmik2319
f1204ccbb6 Translated using Weblate (Hindi)
Currently translated at 16.7% (87 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 07:13:02 -04:00
dharmik2319
77b27e490e Translated using Weblate (Hindi)
Currently translated at 16.5% (86 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 07:11:56 -04:00
dharmik2319
6d1fac382b Translated using Weblate (Hindi)
Currently translated at 16.3% (85 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 07:11:23 -04:00
dharmik2319
c24a7dcc3a Translated using Weblate (Hindi)
Currently translated at 15.9% (83 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 07:09:43 -04:00
dharmik2319
461a6939cd Translated using Weblate (Hindi)
Currently translated at 15.7% (82 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 07:09:10 -04:00
dharmik2319
ce13ff4c11 Translated using Weblate (Hindi)
Currently translated at 15.6% (81 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 07:08:04 -04:00
dharmik2319
70fe0ecd2c Translated using Weblate (Hindi)
Currently translated at 15.4% (80 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 07:05:52 -04:00
dharmik2319
0630bbd9ec Translated using Weblate (Hindi)
Currently translated at 15.2% (79 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 06:59:47 -04:00
dharmik2319
40b941ce4b Translated using Weblate (Hindi)
Currently translated at 14.8% (77 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 06:59:14 -04:00
dharmik2319
ab2298feff Translated using Weblate (Hindi)
Currently translated at 14.6% (76 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 06:58:08 -04:00
dharmik2319
ed481e6492 Translated using Weblate (Hindi)
Currently translated at 14.4% (75 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 06:35:49 -04:00
dharmik2319
3f96cab559 Translated using Weblate (Hindi)
Currently translated at 14.2% (74 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 06:35:16 -04:00
dharmik2319
47ffc9f8ee Translated using Weblate (Hindi)
Currently translated at 14.0% (73 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 06:33:37 -04:00
dharmik2319
8b14af8770 Translated using Weblate (Hindi)
Currently translated at 13.8% (72 of 519 strings)

Translation: Jellyfin Android/Jellyfin Android TV
Translate-URL: https://translate.jellyfin.org/projects/jellyfin-android/jellyfin-androidtv/hi/
2024-08-04 06:33:04 -04:00
Niels van Velzen
4e8fd15508 Support float preferences 2024-08-04 10:47:30 +02:00
Filip Czaplicki
8230479059 Fix typos 2024-08-03 10:54:38 +02:00
renovate[bot]
96e39c34f3 chore(deps): update ci dependencies 2024-08-03 10:41:15 +02:00
238 changed files with 5488 additions and 2583 deletions

View File

@@ -12,23 +12,23 @@ permissions:
jobs:
build:
name: Build
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Java
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
with:
distribution: temurin
java-version: 17
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
uses: gradle/actions/setup-gradle@cc4fc85e6b35bafd578d5ffbc76a5518407e1af0 # v4.2.1
- name: Assemble debug APKs
run: ./gradlew assembleDebug
- name: Create publish bundle
run: mkdir -p build/gh-app-publish/; find app/build/ -iname "*.apk" -exec mv "{}" build/gh-app-publish/ \;
- name: Upload artifacts
uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: build-artifacts
retention-days: 14

View File

@@ -14,21 +14,21 @@ permissions:
jobs:
lint:
name: Lint
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Java
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
with:
distribution: temurin
java-version: 17
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
uses: gradle/actions/setup-gradle@cc4fc85e6b35bafd578d5ffbc76a5518407e1af0 # v4.2.1
- name: Run detekt and lint tasks
run: ./gradlew detekt lint
- name: Upload SARIF files
uses: github/codeql-action/upload-sarif@5cf07d8b700b67e235fbb65cbc84f69c0cf10464 # v3.25.14
uses: github/codeql-action/upload-sarif@aa578102511db1f4524ed59b8cc2bae4f6e88195 # v3.27.6
if: ${{ always() }}
with:
sarif_file: .

View File

@@ -8,18 +8,18 @@ on:
jobs:
publish:
name: Publish
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
if: ${{ contains(github.repository_owner, 'jellyfin') }}
steps:
- name: Checkout repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Java
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
with:
distribution: temurin
java-version: 17
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
uses: gradle/actions/setup-gradle@cc4fc85e6b35bafd578d5ffbc76a5518407e1af0 # v4.2.1
- name: Set JELLYFIN_VERSION
run: echo "JELLYFIN_VERSION=$(echo ${GITHUB_REF#refs/tags/v} | tr / -)" >> $GITHUB_ENV
- name: Assemble release files
@@ -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@029f5b4aeeeb58fdfe1410a5d17f967dacf36262 # v1.0.3
uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0
with:
host: ${{ secrets.REPO_HOST }}
username: ${{ secrets.REPO_USER }}

View File

@@ -13,16 +13,16 @@ permissions:
jobs:
test:
name: Test
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Java
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
with:
distribution: temurin
java-version: 17
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
uses: gradle/actions/setup-gradle@cc4fc85e6b35bafd578d5ffbc76a5518407e1af0 # v4.2.1
- name: Run test task
run: ./gradlew test

View File

@@ -14,9 +14,9 @@ permissions:
jobs:
validate:
name: Validate
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
uses: gradle/actions/wrapper-validation@cc4fc85e6b35bafd578d5ffbc76a5518407e1af0 # v4.2.1

View File

@@ -9,7 +9,7 @@ on:
jobs:
triage:
name: Triage
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
if: ${{ contains(github.repository_owner, 'jellyfin') }}
steps:
- uses: eps1lon/actions-label-merge-conflict@1b1b1fcde06a9b3d089f3464c96417961dde1168 # v3.0.2

View File

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

View File

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

View File

@@ -129,6 +129,7 @@ dependencies {
implementation(libs.androidx.cardview)
implementation(libs.androidx.startup)
implementation(libs.bundles.androidx.compose)
implementation(libs.androidx.tv.material)
// Dependency Injection
implementation(libs.bundles.koin)

View File

@@ -108,8 +108,8 @@
<activity
android:name=".ui.startup.StartupActivity"
android:exported="true"
android:noHistory="true"
android:launchMode="singleTask"
android:noHistory="true"
android:screenOrientation="landscape"
android:windowSoftInputMode="adjustNothing">
<intent-filter>
@@ -132,6 +132,7 @@
<!-- Main application -->
<activity
android:name=".ui.browsing.MainActivity"
android:launchMode="singleTask"
android:screenOrientation="landscape"
android:windowSoftInputMode="adjustNothing" />

View File

@@ -3,7 +3,7 @@ package org.jellyfin.androidtv.auth.model
sealed class QuickConnectState
/**
* State unknown untill first poll completed.
* State unknown until first poll completed.
*/
data object UnknownQuickConnectState : QuickConnectState()

View File

@@ -49,7 +49,7 @@ interface ServerRepository {
val minimumServerVersion = Jellyfin.minimumVersion.copy(build = null)
val recommendedServerVersion = Jellyfin.apiVersion.copy(build = null)
val upcomingMinimumServerVersion = ServerVersion(10, 9, 0)
val upcomingMinimumServerVersion = ServerVersion(10, 10, 0)
}
}
@@ -95,7 +95,7 @@ class ServerRepositoryImpl(
val goodRecommendations = mutableListOf<RecommendedServerInfo>()
val badRecommendations = mutableListOf<RecommendedServerInfo>()
val greatRecommendaton = jellyfin.discovery.getRecommendedServers(addressCandidates).firstOrNull { recommendedServer ->
val greatRecommendation = jellyfin.discovery.getRecommendedServers(addressCandidates).firstOrNull { recommendedServer ->
when (recommendedServer.score) {
RecommendedServerInfoScore.GREAT -> true
RecommendedServerInfoScore.GOOD -> {
@@ -111,7 +111,7 @@ class ServerRepositoryImpl(
Timber.d(buildString {
append("Recommendations: ")
if (greatRecommendaton == null) append(0)
if (greatRecommendation == null) append(0)
else append(1)
append(" great, ")
append(goodRecommendations.size)
@@ -120,7 +120,7 @@ class ServerRepositoryImpl(
append(" bad")
})
val chosenRecommendation = greatRecommendaton ?: goodRecommendations.firstOrNull()
val chosenRecommendation = greatRecommendation ?: goodRecommendations.firstOrNull()
if (chosenRecommendation != null && chosenRecommendation.systemInfo.isSuccess) {
// Get system info
val systemInfo = chosenRecommendation.systemInfo.getOrThrow()

View File

@@ -1,16 +1,14 @@
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.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.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.SubtitleDeliveryMethod;
import java.util.ArrayList;
import java.util.UUID;
@@ -36,14 +34,14 @@ public class StreamInfo {
MediaUrl = value;
}
private PlayMethod PlayMethod = getPlayMethod().values()[0];
private PlayMethod playMethod = PlayMethod.DirectPlay;
public final PlayMethod getPlayMethod() {
return PlayMethod;
return playMethod;
}
public final void setPlayMethod(PlayMethod value) {
PlayMethod = value;
playMethod = value;
}
private EncodingContext Context = EncodingContext.values()[0];
@@ -66,16 +64,6 @@ public class StreamInfo {
Container = value;
}
private long StartPositionTicks;
public final long getStartPositionTicks() {
return StartPositionTicks;
}
public final void setStartPositionTicks(long value) {
StartPositionTicks = value;
}
private DeviceProfile DeviceProfile;
public final DeviceProfile getDeviceProfile() {
@@ -112,10 +100,10 @@ public class StreamInfo {
MediaSource = value;
}
private SubtitleDeliveryMethod SubtitleDeliveryMethod = getSubtitleDeliveryMethod().values()[0];
public final SubtitleDeliveryMethod getSubtitleDeliveryMethod() {
return SubtitleDeliveryMethod;
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 String PlaySessionId;
@@ -139,15 +127,13 @@ public class StreamInfo {
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);
SubtitleStreamInfo info = getSubtitleStreamInfo(stream, getDeviceProfile().getSubtitleProfiles());
list.add(info);
}
}
}
@@ -155,43 +141,15 @@ public class StreamInfo {
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) {
private SubtitleStreamInfo getSubtitleStreamInfo(org.jellyfin.sdk.model.api.MediaStream stream, SubtitleProfile[] subtitleProfiles) {
SubtitleProfile subtitleProfile = StreamBuilder.getSubtitleProfile(stream, subtitleProfiles, getPlayMethod());
SubtitleStreamInfo tempVar = new SubtitleStreamInfo();
SubtitleStreamInfo info = 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());
}
}
info.setName((tempVar2 != null) ? tempVar2 : "Unknown");
info.setFormat(subtitleProfile.getFormat());
info.setIndex(stream.getIndex());
info.setDeliveryMethod(subtitleProfile.getMethod());
info.setDisplayTitle(stream.getDisplayTitle());
return info;
}

View File

@@ -16,6 +16,7 @@ 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.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
@@ -104,7 +105,7 @@ class SocketHandler(
val index = message["index"]?.toIntOrNull() ?: return@onEach
withContext(Dispatchers.Main) {
playbackControllerContainer.playbackController?.switchSubtitleStream(index)
playbackControllerContainer.playbackController?.setSubtitleIndex(index)
}
}
.launchIn(coroutineScope)

View File

@@ -21,7 +21,6 @@ class UserViewsRepositoryImpl(
override val views = flow {
val views by api.userViewsApi.getUserViews()
val filteredViews = views.items
.orEmpty()
.filter { isSupported(it.collectionType) }
emit(filteredViews)
}

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 androidx.core.graphics.drawable.toBitmap
import coil.ImageLoader
import coil.request.ImageRequest
import coil3.ImageLoader
import coil3.request.ImageRequest
import coil3.toBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
@@ -124,7 +124,7 @@ class BackgroundService(
_backgrounds = backdropUrls.mapNotNull { url ->
imageLoader.execute(
request = ImageRequest.Builder(context).data(url).build()
).drawable?.toBitmap()?.asImageBitmap()
).image?.toBitmap()?.asImageBitmap()
}
// Go to first background

View File

@@ -2,10 +2,12 @@ package org.jellyfin.androidtv.di
import android.content.Context
import android.os.Build
import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import coil3.ImageLoader
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.serviceLoaderEnabled
import coil3.svg.SvgDecoder
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.auth.repository.ServerRepository
import org.jellyfin.androidtv.auth.repository.UserRepository
@@ -30,6 +32,8 @@ import org.jellyfin.androidtv.ui.navigation.NavigationRepositoryImpl
import org.jellyfin.androidtv.ui.picture.PictureViewerViewModel
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
import org.jellyfin.androidtv.ui.playback.nextup.NextUpViewModel
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepository
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepositoryImpl
import org.jellyfin.androidtv.ui.search.SearchFragmentDelegate
import org.jellyfin.androidtv.ui.search.SearchRepository
import org.jellyfin.androidtv.ui.search.SearchRepositoryImpl
@@ -52,7 +56,7 @@ 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.androidx.viewmodel.dsl.viewModel
import org.koin.core.module.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
import org.jellyfin.apiclient.Jellyfin as JellyfinApiClient
@@ -101,8 +105,10 @@ val appModule = module {
// Coil (images)
single {
ImageLoader.Builder(androidContext()).apply {
serviceLoaderEnabled(false)
components {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) add(ImageDecoderDecoder.Factory())
add(OkHttpNetworkFetcherFactory())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) add(AnimatedImageDecoder.Factory())
else add(GifDecoder.Factory())
add(SvgDecoder.Factory())
}
@@ -120,6 +126,7 @@ val appModule = module {
single<CustomMessageRepository> { CustomMessageRepositoryImpl() }
single<NavigationRepository> { NavigationRepositoryImpl(Destinations.home) }
single<SearchRepository> { SearchRepositoryImpl(get()) }
single<MediaSegmentRepository> { MediaSegmentRepositoryImpl(get(), get()) }
viewModel { StartupViewModel(get(), get(), get(), get()) }
viewModel { UserLoginViewModel(get(), get(), get(), get(defaultDeviceInfo)) }
@@ -135,7 +142,7 @@ val appModule = module {
single { MarkdownRenderer(get()) }
single { ItemLauncher() }
single { KeyProcessor() }
single { ReportingHelper() }
single { ReportingHelper(get(), get()) }
single<PlaybackHelper> { SdkPlaybackHelper(get(), get(), get(), get(), get(), get(), get()) }
factory { (context: Context) -> SearchFragmentDelegate(context, get(), get()) }

View File

@@ -59,10 +59,13 @@ fun Scope.createPlaybackManager() = playbackManager(androidContext()) {
NotificationManagerCompat.from(get()).createNotificationChannel(channel)
}
val userPreferences = get<UserPreferences>()
val api = get<ApiClient>()
val exoPlayerOptions = ExoPlayerOptions(
httpConnectTimeout = api.httpClientOptions.connectTimeout,
httpReadTimeout = api.httpClientOptions.requestTimeout
httpReadTimeout = api.httpClientOptions.requestTimeout,
preferFfmpeg = userPreferences[UserPreferences.preferExoPlayerFfmpeg],
enableDebugLogging = userPreferences[UserPreferences.debuggingEnabled],
)
install(exoPlayerPlugin(get(), exoPlayerOptions))

View File

@@ -28,6 +28,7 @@ import org.jellyfin.androidtv.ui.startup.StartupActivity
import org.jellyfin.androidtv.util.ImageHelper
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
@@ -220,7 +221,6 @@ class LeanbackChannelWorker(
// Add new items
return response.items
.orEmpty()
.filter { userViewsRepository.isSupported(it.collectionType) }
}
@@ -276,7 +276,7 @@ class LeanbackChannelWorker(
mediaTypes = listOf(MediaType.VIDEO),
includeItemTypes = listOf(BaseItemKind.EPISODE, BaseItemKind.MOVIE),
excludeActiveSessions = true,
).content.items.orEmpty()
).content.items
}
val nextUp = async {
@@ -285,7 +285,7 @@ class LeanbackChannelWorker(
limit = 10,
enableResumable = false,
fields = listOf(ItemFields.DATE_CREATED),
).content.items.orEmpty()
).content.items
}
// Concat
@@ -363,7 +363,7 @@ class LeanbackChannelWorker(
.setEpisodeTitle(if (item.type == BaseItemKind.EPISODE) item.name else null)
.setSeasonNumber(seasonString, item.parentIndexNumber ?: 0)
.setEpisodeNumber(episodeString, item.indexNumber ?: 0)
.setDescription(item.overview)
.setDescription(item.overview?.stripHtml())
.setReleaseDate(
if (item.premiereDate != null) DateTimeFormatter.ISO_DATE.format(item.premiereDate)
else null

View File

@@ -2,18 +2,17 @@ package org.jellyfin.androidtv.integration.dream
import android.annotation.SuppressLint
import android.content.Context
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import coil.ImageLoader
import coil.request.ImageRequest
import coil3.ImageLoader
import coil3.request.ImageRequest
import coil3.toBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
@@ -22,14 +21,13 @@ 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.ui.playback.AudioEventListener
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.androidtv.ui.playback.PlaybackController
import org.jellyfin.playback.core.PlaybackManager
import org.jellyfin.playback.core.queue.queue
import org.jellyfin.playback.jellyfin.queue.baseItem
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageFormat
import org.jellyfin.sdk.model.api.ImageType
@@ -42,35 +40,17 @@ class DreamViewModel(
private val api: ApiClient,
private val imageLoader: ImageLoader,
private val context: Context,
private val mediaManager: MediaManager,
playbackManager: PlaybackManager,
private val userPreferences: UserPreferences,
) : ViewModel() {
private val _mediaContent = callbackFlow {
trySend(mediaManager.currentAudioItem)
val listener = object : AudioEventListener {
override fun onPlaybackStateChange(
newState: PlaybackController.PlaybackState,
currentItem: BaseItemDto?
) {
trySend(currentItem)
}
override fun onQueueStatusChanged(hasQueue: Boolean) {
trySend(mediaManager.currentAudioItem)
@OptIn(ExperimentalCoroutinesApi::class)
private val _mediaContent = playbackManager.queue.entry
.map { entry ->
entry?.baseItem?.let { baseItem ->
DreamContent.NowPlaying(entry, baseItem)
}
}
mediaManager.addAudioEventListener(listener)
awaitClose { mediaManager.removeAudioEventListener(listener) }
}
.distinctUntilChanged()
.map { it?.let(DreamContent::NowPlaying) }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(),
DreamContent.NowPlaying(mediaManager.currentAudioItem)
)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
private val _libraryContent = flow {
// Load first library item after 2 seconds
@@ -114,7 +94,7 @@ class DreamViewModel(
hasParentalRating = if (requireParentalRating) true else null,
)
val item = response.items?.firstOrNull { item ->
val item = response.items.firstOrNull { item ->
!item.backdropImageTags.isNullOrEmpty()
} ?: return null
@@ -143,13 +123,13 @@ class DreamViewModel(
val logoDeferred = async {
imageLoader.execute(
request = ImageRequest.Builder(context).data(logoUrl).build()
).drawable?.toBitmap()
).image?.toBitmap()
}
val backdropDeferred = async {
imageLoader.execute(
request = ImageRequest.Builder(context).data(backdropUrl).build()
).drawable?.toBitmap()
).image?.toBitmap()
}
awaitAll(logoDeferred, backdropDeferred)

View File

@@ -17,7 +17,7 @@ import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import org.jellyfin.androidtv.integration.dream.model.DreamContent
import org.jellyfin.androidtv.ui.composable.ZoomBox
import org.jellyfin.androidtv.ui.composable.overscan
import org.jellyfin.androidtv.ui.composable.modifier.overscan
@Composable
fun DreamContentLibraryShowcase(

View File

@@ -14,11 +14,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
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
@@ -32,18 +29,20 @@ import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import org.jellyfin.androidtv.integration.dream.model.DreamContent
import org.jellyfin.androidtv.ui.composable.AsyncImage
import org.jellyfin.androidtv.ui.composable.LyricsDtoBox
import org.jellyfin.androidtv.ui.composable.blurHashPainter
import org.jellyfin.androidtv.ui.composable.overscan
import org.jellyfin.androidtv.ui.playback.AudioEventListener
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.androidtv.ui.composable.modifier.fadingEdges
import org.jellyfin.androidtv.ui.composable.modifier.overscan
import org.jellyfin.androidtv.ui.composable.rememberPlayerProgress
import org.jellyfin.playback.core.PlaybackManager
import org.jellyfin.playback.core.model.PlayState
import org.jellyfin.playback.jellyfin.lyrics
import org.jellyfin.playback.jellyfin.lyricsFlow
import org.jellyfin.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.jellyfin.sdk.model.extensions.ticks
import org.koin.compose.koinInject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun DreamContentNowPlaying(
@@ -52,18 +51,21 @@ fun DreamContentNowPlaying(
modifier = Modifier.fillMaxSize(),
) {
val api = koinInject<ApiClient>()
val mediaManager = koinInject<MediaManager>()
val item = content.item ?: return@Box
val playbackManager = koinInject<PlaybackManager>()
val lyrics = content.entry.run { lyricsFlow.collectAsState(lyrics) }.value
val progress = rememberPlayerProgress(playbackManager)
val primaryImageTag = item.imageTags?.get(ImageType.PRIMARY)
val primaryImageTag = content.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
primaryImageTag != null -> content.item.id to primaryImageTag
(content.item.albumId != null && content.item.albumPrimaryImageTag != null) -> content.item.albumId to content.item.albumPrimaryImageTag
else -> null to null
}
val imageBlurHash =
imageTag?.let { tag -> item.imageBlurHashes?.get(ImageType.PRIMARY)?.get(tag) }
// Background
val imageBlurHash = imageTag?.let { tag ->
content.item.imageBlurHashes?.get(ImageType.PRIMARY)?.get(tag)
}
if (imageBlurHash != null) {
Image(
painter = blurHashPainter(imageBlurHash, IntSize(32, 32)),
@@ -76,7 +78,24 @@ fun DreamContentNowPlaying(
DreamContentVignette()
}
// Overlay
// Lyrics overlay (on top of background)
if (lyrics != null) {
val playState by playbackManager.state.playState.collectAsState()
LyricsDtoBox(
lyricDto = lyrics,
currentTimestamp = playbackManager.state.positionInfo.active,
duration = playbackManager.state.positionInfo.duration,
paused = playState != PlayState.PLAYING,
fontSize = 22.sp,
color = Color.White,
modifier = Modifier
.fillMaxSize()
.fadingEdges(vertical = 250.dp)
.padding(horizontal = 50.dp),
)
}
// Metadata overlay (includes title / progress)
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(20.dp),
@@ -105,7 +124,7 @@ fun DreamContentNowPlaying(
.padding(bottom = 10.dp)
) {
Text(
text = item.name.orEmpty(),
text = content.item.name.orEmpty(),
style = TextStyle(
color = Color.White,
fontSize = 26.sp,
@@ -113,7 +132,7 @@ fun DreamContentNowPlaying(
)
Text(
text = item.run {
text = content.item.run {
val artistNames = artists.orEmpty()
val albumArtistNames = albumArtists?.mapNotNull { it.name }.orEmpty()
@@ -131,22 +150,6 @@ fun DreamContentNowPlaying(
Spacer(modifier = Modifier.height(10.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)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
@@ -156,7 +159,10 @@ fun DreamContentNowPlaying(
// Background
drawRect(Color.White, alpha = 0.2f)
// Foreground
drawRect(Color.White, size = size.copy(width = size.width * progress))
drawRect(
Color.White,
size = size.copy(width = progress * size.width)
)
}
)
}

View File

@@ -21,7 +21,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.composable.overscan
import org.jellyfin.androidtv.ui.composable.modifier.overscan
import org.jellyfin.androidtv.ui.composable.rememberCurrentTime
@Composable

View File

@@ -1,10 +1,11 @@
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 item: BaseItemDto?) : DreamContent
data class NowPlaying(val entry: QueueEntry, val item: BaseItemDto) : DreamContent
}

View File

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

View File

@@ -10,12 +10,17 @@ import org.jellyfin.androidtv.preference.constant.NextUpBehavior
import org.jellyfin.androidtv.preference.constant.RatingType
import org.jellyfin.androidtv.preference.constant.RefreshRateSwitchingBehavior
import org.jellyfin.androidtv.preference.constant.WatchedIndicatorBehavior
import org.jellyfin.androidtv.preference.constant.ZoomMode
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentAction
import org.jellyfin.androidtv.ui.playback.segment.toMediaSegmentActionsString
import org.jellyfin.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
/**
@@ -45,7 +50,7 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
var premieresEnabled = booleanPreference("pref_enable_premieres", false)
/**
* Enable management of media like deleting items when the user has sufficient permisisons.
* Enable management of media like deleting items when the user has sufficient permissions.
*/
var mediaManagementEnabled = booleanPreference("enable_media_management", false)
@@ -92,11 +97,16 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
*/
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.DOWNMIX_TO_STEREO)
var audioBehaviour = enumPreference("audio_behavior", AudioBehavior.DIRECT_STREAM)
/**
* Preferred behavior for audio streaming.
@@ -156,30 +166,25 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
var seriesThumbnailsEnabled = booleanPreference("pref_enable_series_thumbnails", true)
/**
* Enable subtitles background
* Subtitles foreground color
*/
var subtitlesBackgroundEnabled = booleanPreference("subtitles_background_enabled", true)
/**
* Subtitles font size
*/
var subtitlesSize = intPreference("subtitles_size", 28)
/**
* Subtitles stroke size
*/
var subtitleStrokeSize = intPreference("subtitles_stroke_size", 0)
/**
* Subtitles position
*/
var subtitlePosition = intPreference("subtitles_position", 40)
var subtitlesBackgroundColor = longPreference("subtitles_background_color", 0x00FFFFFF)
/**
* Subtitles foreground color
*/
var subtitlesTextColor = longPreference("subtitles_text_color", 0xFFFFFFFF)
/**
* Subtitles stroke color
*/
var subtitleTextStrokeColor = longPreference("subtitles_text_stroke_color", 0xFF000000)
/**
* Subtitles font size
*/
var subtitlesTextSize = floatPreference("subtitles_text_size", 1f)
/**
* Show screensaver in app
*/
@@ -201,9 +206,30 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
var screensaverAgeRatingRequired = booleanPreference("screensaver_agerating_required", true)
/**
* Enable reactive homepage
* Delay when starting video playback after loading the video player.
*/
var homeReactive = booleanPreference("home_reactive", false)
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)
}
init {
@@ -216,6 +242,17 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
// Enable playback rewrite for music
putBoolean("playback_new_audio", true)
}
// v0.17.z to v0.18.0
migration(toVersion = 8) {
// Set subtitle background color to black if it was enabled in a previous version
val subtitlesBackgroundEnabled = it.getBoolean("subtitles_background_enabled", true)
putLong("subtitles_background_color", if (subtitlesBackgroundEnabled) 0XFF000000L else 0X00FFFFFFL)
// Set subtitle text stroke color to black if it was enabled in a previous version
val subtitleStrokeSize = it.getInt("subtitles_stroke_size", 0)
putLong("subtitles_text_stroke_color", if (subtitleStrokeSize > 0) 0XFF000000L else 0X00FFFFFFL)
}
}
}
}

View File

@@ -24,10 +24,26 @@ 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
get() = listOf(homesection0, homesection1, homesection2, homesection3, homesection4, homesection5, homesection6)
val homesections = listOf(
homesection0,
homesection1,
homesection2,
homesection3,
homesection4,
homesection5,
homesection6,
homesection7,
homesection8,
homesection9,
)
val activeHomesections
get() = homesections
.map(::get)
.filterNot { it == HomeSectionType.NONE }
}

View File

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

View File

@@ -82,6 +82,9 @@ 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
@@ -96,6 +99,10 @@ 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()
}

View File

@@ -9,9 +9,13 @@ import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.doOnAttach
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
import coil.ImageLoader
import coil.request.ImageRequest
import coil.transform.CircleCropTransformation
import coil3.ImageLoader
import coil3.asImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import coil3.request.target
import coil3.request.transformations
import coil3.transform.CircleCropTransformation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -88,9 +92,9 @@ class AsyncImageView @JvmOverloads constructor(
target(this@AsyncImageView)
data(url)
placeholder(placeholderOrBlurHash)
placeholder(placeholderOrBlurHash?.asImage())
if (circleCrop) transformations(CircleCropTransformation())
error(placeholder)
error(placeholder?.asImage())
}.build())
}
}

View File

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

View File

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

View File

@@ -29,7 +29,6 @@ import androidx.leanback.widget.VerticalGridPresenter;
import androidx.lifecycle.Lifecycle;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.auth.repository.UserRepository;
import org.jellyfin.androidtv.constant.ChangeTriggerType;
import org.jellyfin.androidtv.constant.CustomMessage;
import org.jellyfin.androidtv.constant.Extras;
@@ -117,7 +116,6 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
private final Lazy<BackgroundService> backgroundService = inject(BackgroundService.class);
private final Lazy<PreferencesRepository> preferencesRepository = inject(PreferencesRepository.class);
private final Lazy<UserViewsRepository> userViewsRepository = inject(UserViewsRepository.class);
private final Lazy<UserRepository> userRepository = inject(UserRepository.class);
private final Lazy<CustomMessageRepository> customMessageRepository = inject(CustomMessageRepository.class);
private final Lazy<NavigationRepository> navigationRepository = inject(NavigationRepository.class);
private final Lazy<ItemLauncher> itemLauncher = inject(ItemLauncher.class);
@@ -169,7 +167,12 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
sortOptions.put(3, new SortOption(getString(R.string.lbl_rating), ItemSortBy.OFFICIAL_RATING, SortOrder.ASCENDING));
sortOptions.put(4, new SortOption(getString(R.string.lbl_community_rating), ItemSortBy.COMMUNITY_RATING, SortOrder.DESCENDING));
sortOptions.put(5, new SortOption(getString(R.string.lbl_critic_rating), ItemSortBy.CRITIC_RATING, SortOrder.DESCENDING));
sortOptions.put(6, new SortOption(getString(R.string.lbl_last_played), ItemSortBy.DATE_PLAYED, SortOrder.DESCENDING));
if (mFolder.getCollectionType() == CollectionType.TVSHOWS) {
sortOptions.put(6, new SortOption(getString(R.string.lbl_last_played), ItemSortBy.SERIES_DATE_PLAYED, SortOrder.DESCENDING));
} else {
sortOptions.put(6, new SortOption(getString(R.string.lbl_last_played), ItemSortBy.DATE_PLAYED, SortOrder.DESCENDING));
}
if (mFolder.getCollectionType() != null && mFolder.getCollectionType() == CollectionType.MOVIES) {
sortOptions.put(7, new SortOption(getString(R.string.lbl_runtime), ItemSortBy.RUNTIME, SortOrder.ASCENDING));
@@ -185,12 +188,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
binding = HorizontalGridBrowseBinding.inflate(inflater, container, false);
// Hide the description because we don't have room for it
binding.npBug.showDescription(false);
return binding.getRoot();
}
@@ -473,11 +471,11 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
if (numRows > 0) {
double paddingPct = cardScaling / numRows;
double spaceingPct = ((paddingPct / 2.0) * CARD_SPACING_PCT) * (numRows - 1);
double spacingPct = ((paddingPct / 2.0) * CARD_SPACING_PCT) * (numRows - 1);
double wastedSpacePct = paddingPct + spaceingPct;
double useableCardSpace = mGridHeight / (1.0 + wastedSpacePct); // decrease size
double cardHeight = useableCardSpace / numRows;
double wastedSpacePct = paddingPct + spacingPct;
double usableCardSpace = mGridHeight / (1.0 + wastedSpacePct); // decrease size
double cardHeight = usableCardSpace / numRows;
// fix any rounding errors and make pixel perfect
cardHeightInt = (int) Math.round(cardHeight);
@@ -501,14 +499,14 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
mCardsScreenStride = numRows;
} else if (numCols > 0) {
double paddingPct = cardScaling / numCols;
double spaceingPct = ((paddingPct / 2.0) * CARD_SPACING_PCT) * (numCols - 1);
double spacingPct = ((paddingPct / 2.0) * CARD_SPACING_PCT) * (numCols - 1);
if (mImageType == ImageType.BANNER) {
spaceingPct = ((paddingPct / 2.0) * CARD_SPACING_HORIZONTAL_BANNER_PCT) * (numCols - 1);
spacingPct = ((paddingPct / 2.0) * CARD_SPACING_HORIZONTAL_BANNER_PCT) * (numCols - 1);
}
double wastedSpacePct = paddingPct + spaceingPct;
double useableCardSpace = mGridWidth / (1.0 + wastedSpacePct); // decrease size
double cardWidth = useableCardSpace / numCols;
double wastedSpacePct = paddingPct + spacingPct;
double usableCardSpace = mGridWidth / (1.0 + wastedSpacePct); // decrease size
double cardWidth = usableCardSpace / numCols;
// fix any rounding errors and make pixel perfect
cardHeightInt = (int) Math.round(getCardHeightBy(cardWidth, mImageType, mFolder));
@@ -621,6 +619,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
chunkSize = Math.min(mCardsScreenEst + mCardsScreenStride, 150); // cap at 150
Timber.d("buildAdapter adjusting chunkSize to <%s> screenEst <%s>", chunkSize, mCardsScreenEst);
}
chunkSize=100;
switch (mRowDef.getQueryType()) {
case NextUp:

View File

@@ -4,7 +4,6 @@ import org.jellyfin.androidtv.constant.ChangeTriggerType;
import org.jellyfin.androidtv.constant.QueryType;
import org.jellyfin.androidtv.data.querying.GetSeriesTimersRequest;
import org.jellyfin.androidtv.data.querying.GetSpecialsRequest;
import org.jellyfin.androidtv.data.querying.GetUserViewsRequest;
import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest;
import org.jellyfin.sdk.model.api.request.GetArtistsRequest;
import org.jellyfin.sdk.model.api.request.GetItemsRequest;
@@ -138,12 +137,6 @@ public class BrowseRowDef {
this.queryType = type;
}
public BrowseRowDef(String header, GetUserViewsRequest query) {
headerText = header;
this.staticHeight = true;
this.queryType = QueryType.Views;
}
public BrowseRowDef(String header, GetResumeItemsRequest query, int chunkSize, boolean preferParentThumb, boolean staticHeight, ChangeTriggerType[] changeTriggers) {
headerText = header;
this.resumeQuery = query;

View File

@@ -345,6 +345,7 @@ object BrowsingUtils {
),
includeItemTypes = setOf(BaseItemKind.EPISODE),
parentId = parentId,
indexNumber = 1,
recursive = true,
isMissing = false,
imageTypeLimit = 1,

View File

@@ -21,7 +21,7 @@ class ByGenreFragment : BrowseFolderFragment() {
sortBy = setOf(ItemSortBy.SORT_NAME),
)
for (genre in genresResponse.items.orEmpty()) {
for (genre in genresResponse.items) {
val itemsRequest = GetItemsRequest(
parentId = folder?.id,
sortBy = setOf(ItemSortBy.SORT_NAME),

View File

@@ -0,0 +1,190 @@
package org.jellyfin.androidtv.ui.browsing
import android.annotation.SuppressLint
import android.content.Context
import android.os.Bundle
import android.os.Parcel
import android.os.Parcelable
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.core.os.BundleCompat
import androidx.core.os.ParcelCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentContainerView
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentTransaction
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.navigation.NavigationAction
import java.util.Stack
private class HistoryEntry(
val name: Class<out Fragment>,
val arguments: Bundle = bundleOf(),
var fragment: Fragment? = null,
var savedState: Fragment.SavedState? = null,
) : Parcelable {
override fun describeContents(): Int = 0
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeString(name.name)
dest.writeBundle(arguments)
dest.writeParcelable(savedState, 0)
}
companion object CREATOR : Parcelable.Creator<HistoryEntry> {
@Suppress("UNCHECKED_CAST")
override fun createFromParcel(parcel: Parcel): HistoryEntry = HistoryEntry(
name = Class.forName(parcel.readString()!!) as Class<out Fragment>,
arguments = parcel.readBundle(this::class.java.classLoader)!!,
fragment = null,
savedState = ParcelCompat.readParcelable(parcel, this::class.java.classLoader, Fragment.SavedState::class.java)!!,
)
override fun newArray(size: Int): Array<HistoryEntry?> = arrayOfNulls(size)
}
}
class DestinationFragmentView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : FrameLayout(context, attrs) {
private companion object {
private const val FRAGMENT_TAG_CONTENT = "content"
private const val BUNDLE_SUPER = "super"
private const val BUNDLE_HISTORY = "history"
}
private val fragmentManager by lazy {
FragmentManager.findFragmentManager(this)
}
private val container by lazy {
FragmentContainerView(context).also { view ->
view.id = R.id.container
addView(view)
}
}
private val history = Stack<HistoryEntry>()
fun navigate(action: NavigationAction.NavigateFragment) {
val entry = HistoryEntry(action.destination.fragment.java, action.destination.arguments)
// Create the base transaction so we can mutate everything at once
val transaction = fragmentManager.beginTransaction()
if (action.clear) {
// Clear all current fragments from the history before adding the new entry
history.mapNotNull { it.fragment }.forEach { transaction.remove(it) }
history.clear()
history.push(entry)
} else if (action.replace && action.addToBackStack && history.isNotEmpty()) {
// Remove the top-most entry before replacing it with the next
val currentFragment = history[history.size - 1].fragment
if (currentFragment != null) transaction.remove(currentFragment)
history[history.size - 1] = entry
} else {
// Add to the end of the history
saveCurrentFragmentState()
history.push(entry)
}
activateHistoryEntry(entry, transaction)
}
fun goBack(): Boolean {
// Require at least 2 items (current & previous) to go back
if (history.size < 2) return false
// Create the base transaction so we can mutate everything at once
val transaction = fragmentManager.beginTransaction()
// Remove current entry
val currentEntry = history.pop()
// Make sure to remove the associated fragment
val currentFragment = currentEntry.fragment
if (currentFragment != null) transaction.remove(currentFragment)
// Read & set previous entry
val entry = history.last()
activateHistoryEntry(entry, transaction)
return true
}
private fun saveCurrentFragmentState() {
if (history.isEmpty()) return
// Update the top-most history entry with state from current fragment
val fragment = requireNotNull(fragmentManager.findFragmentByTag(FRAGMENT_TAG_CONTENT))
history[history.size - 1].savedState = fragmentManager.saveFragmentInstanceState(fragment)
}
@SuppressLint("CommitTransaction")
private fun activateHistoryEntry(
entry: HistoryEntry,
transaction: FragmentTransaction,
) {
var fragment = entry.fragment
// Create if there is no existing fragment
if (fragment == null) {
fragment = fragmentManager.fragmentFactory.instantiate(context.classLoader, entry.name.name).apply {
setInitialSavedState(entry.savedState)
}
entry.fragment = fragment
}
// Update arguments
fragment.arguments = entry.arguments
transaction.apply {
// Set options
setReorderingAllowed(true)
setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out)
// Detach current fragment
fragmentManager.findFragmentByTag(FRAGMENT_TAG_CONTENT)?.let(::detach)
// Attach or add next fragment
if (fragment.isDetached) attach(fragment)
else add(container.id, fragment, FRAGMENT_TAG_CONTENT)
}
// Commit
if (fragmentManager.isStateSaved) transaction.commitNowAllowingStateLoss()
else transaction.commitNow()
}
override fun onSaveInstanceState(): Parcelable {
// Always retrieve current state before writing
saveCurrentFragmentState()
// Save state
return bundleOf(
BUNDLE_SUPER to super.onSaveInstanceState(),
BUNDLE_HISTORY to ArrayList(history),
)
}
override fun onRestoreInstanceState(state: Parcelable?) {
// Ignore if not a bundle
if (state !is Bundle) return super.onRestoreInstanceState(state)
// Call parent
@Suppress("DEPRECATION")
val parent = state.getParcelable<Parcelable>(BUNDLE_SUPER)
super.onRestoreInstanceState(parent)
// Restore history
val savedHistory = BundleCompat.getParcelableArrayList(state, BUNDLE_HISTORY, HistoryEntry::class.java)
if (savedHistory != null) {
history.clear()
history.addAll(savedHistory)
if (history.isNotEmpty()) activateHistoryEntry(history.last(), fragmentManager.beginTransaction())
}
}
}

View File

@@ -9,15 +9,12 @@ import android.view.WindowManager
import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.commit
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.auth.repository.SessionRepository
import org.jellyfin.androidtv.auth.repository.UserRepository
import org.jellyfin.androidtv.databinding.ActivityMainBinding
@@ -34,15 +31,10 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
import timber.log.Timber
class MainActivity : FragmentActivity() {
companion object {
private const val FRAGMENT_TAG_CONTENT = "content"
}
private val navigationRepository by inject<NavigationRepository>()
private val sessionRepository by inject<SessionRepository>()
private val userRepository by inject<UserRepository>()
private val screensaverViewModel by viewModel<ScreensaverViewModel>()
private var inTransaction = false
private lateinit var binding: ActivityMainBinding
@@ -66,16 +58,10 @@ class MainActivity : FragmentActivity() {
}.launchIn(lifecycleScope)
onBackPressedDispatcher.addCallback(this, backPressedCallback)
supportFragmentManager.addOnBackStackChangedListener {
if (!inTransaction && supportFragmentManager.backStackEntryCount == 0)
navigationRepository.reset()
}
if (savedInstanceState == null && navigationRepository.canGoBack) navigationRepository.reset()
if (savedInstanceState == null && navigationRepository.canGoBack) navigationRepository.reset(clearHistory = true)
navigationRepository.currentAction
.flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.onEach { action ->
handleNavigationAction(action)
backPressedCallback.isEnabled = navigationRepository.canGoBack
@@ -124,48 +110,23 @@ class MainActivity : FragmentActivity() {
}
}
private fun handleNavigationAction(action: NavigationAction) = when (action) {
is NavigationAction.NavigateFragment -> {
if (action.clear) {
// Clear the current back stack
val firstBackStackEntry = supportFragmentManager.getBackStackEntryAt(0)
supportFragmentManager.popBackStack(firstBackStackEntry.id, FragmentManager.POP_BACK_STACK_INCLUSIVE)
} else if (action.replace) {
// Prevent back stack changed listener from resetting when popping to
// the initial destination
inTransaction = true
supportFragmentManager.popBackStack()
inTransaction = false
}
private fun handleNavigationAction(action: NavigationAction) {
when (action) {
// DestinationFragmentView actions
is NavigationAction.NavigateFragment -> binding.contentView.navigate(action)
NavigationAction.GoBack -> binding.contentView.goBack()
supportFragmentManager.commit {
// Others
is NavigationAction.NavigateActivity -> {
val destination = action.destination
val currentFragment = supportFragmentManager.findFragmentByTag(FRAGMENT_TAG_CONTENT)
val isSameFragment = currentFragment != null &&
destination.fragment.isInstance(currentFragment) &&
currentFragment.arguments == destination.arguments
if (!isSameFragment) {
setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out)
replace(R.id.content_view, destination.fragment.java, destination.arguments, FRAGMENT_TAG_CONTENT)
}
if (action.addToBackStack) addToBackStack(null)
val intent = Intent(this@MainActivity, destination.activity.java)
intent.putExtras(destination.extras)
startActivity(intent)
action.onOpened()
}
NavigationAction.Nothing -> Unit
}
is NavigationAction.NavigateActivity -> {
val destination = action.destination
val intent = Intent(this@MainActivity, destination.activity.java)
intent.putExtras(destination.extras)
startActivity(intent)
action.onOpened()
}
NavigationAction.GoBack -> supportFragmentManager.popBackStack()
NavigationAction.Nothing -> Unit
}
// Forward key events to fragments

View File

@@ -31,7 +31,7 @@ class SuggestedMoviesFragment : EnhancedBrowseFragment() {
recursive = true,
)
for (item in response.items.orEmpty()) {
for (item in response.items) {
val similar = GetSimilarItemsRequest(
itemId = item.id,
fields = setOf(

View File

@@ -26,6 +26,8 @@ import org.jellyfin.androidtv.util.sdk.getSeasonEpisodeName
import org.jellyfin.androidtv.util.sdk.isNew
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
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.SeriesStatus
import org.jellyfin.sdk.model.api.VideoRangeType
@@ -135,13 +137,23 @@ fun InfoRowSeasonEpisode(item: BaseItemDto) {
}
}
private fun List<MediaStream>.getDefault(type: MediaStreamType, defaultIndex: Int? = null): MediaStream? {
if (defaultIndex != null) {
val byIndex = getOrNull(defaultIndex)
if (byIndex?.type == type) return byIndex
}
return firstOrNull { it.type == type }
}
@Composable
fun InfoRowMediaDetails(item: BaseItemDto) {
val videoStream = item.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }
val audioStream = item.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO }
fun InfoRowMediaDetails(mediaSource: MediaSourceInfo) {
val videoStream = mediaSource.mediaStreams?.getDefault(MediaStreamType.VIDEO)
val audioStream = mediaSource.mediaStreams?.getDefault(MediaStreamType.AUDIO, mediaSource.defaultAudioStreamIndex)
val subtitleStream = mediaSource.mediaStreams?.getDefault(MediaStreamType.SUBTITLE, mediaSource.defaultSubtitleStreamIndex)
// Subtitles
if (item.hasSubtitles == true) {
if (subtitleStream != null) {
InfoRowItem(
contentDescription = null,
colors = InfoRowColors.Default,
@@ -219,6 +231,7 @@ fun InfoRowMediaDetails(item: BaseItemDto) {
@Composable
fun BaseItemInfoRow(
item: BaseItemDto,
mediaSource: MediaSourceInfo?,
includeRuntime: Boolean,
) {
val userPreferences = koinInject<UserPreferences>()
@@ -239,7 +252,7 @@ fun BaseItemInfoRow(
InfoRowDate(item)
if (includeRuntime) item.runTimeTicks?.ticks?.let { BaseItemInfoRowRuntime(it) }
item.officialRating?.let { InfoRowParentalRating(it) }
InfoRowMediaDetails(item)
mediaSource?.let { InfoRowMediaDetails(it) }
}
BaseItemKind.BOX_SET -> {
@@ -257,7 +270,7 @@ fun BaseItemInfoRow(
val runtime = item.cumulativeRunTimeTicks ?: item.runTimeTicks
if (includeRuntime) runtime?.ticks?.let { BaseItemInfoRowRuntime(it) }
item.officialRating?.let { InfoRowParentalRating(it) }
InfoRowMediaDetails(item)
mediaSource?.let { InfoRowMediaDetails(it) }
}
BaseItemKind.SERIES -> {
@@ -265,7 +278,7 @@ fun BaseItemInfoRow(
if (includeRuntime) item.runTimeTicks?.ticks?.let { BaseItemInfoRowRuntime(it) }
InfoRowSeriesStatus(item)
item.officialRating?.let { InfoRowParentalRating(it) }
InfoRowMediaDetails(item)
mediaSource?.let { InfoRowMediaDetails(it) }
}
BaseItemKind.PROGRAM -> {
@@ -303,7 +316,7 @@ fun BaseItemInfoRow(
if (includeRuntime) item.runTimeTicks?.ticks?.let { BaseItemInfoRowRuntime(it) }
item.officialRating?.let { InfoRowParentalRating(it) }
InfoRowMediaDetails(item)
mediaSource?.let { InfoRowMediaDetails(it) }
}
BaseItemKind.MUSIC_ARTIST -> {
@@ -343,14 +356,14 @@ fun BaseItemInfoRow(
val runtime = item.cumulativeRunTimeTicks ?: item.runTimeTicks
if (includeRuntime) runtime?.ticks?.let { BaseItemInfoRowRuntime(it) }
item.officialRating?.let { InfoRowParentalRating(it) }
InfoRowMediaDetails(item)
mediaSource?.let { InfoRowMediaDetails(it) }
}
else -> {
InfoRowDate(item)
if (includeRuntime) item.runTimeTicks?.ticks?.let { BaseItemInfoRowRuntime(it) }
item.officialRating?.let { InfoRowParentalRating(it) }
InfoRowMediaDetails(item)
mediaSource?.let { InfoRowMediaDetails(it) }
}
}
}
@@ -364,6 +377,7 @@ class BaseItemInfoRowView @JvmOverloads constructor(
attrs: AttributeSet? = null,
) : AbstractComposeView(context, attrs) {
private val _item = MutableStateFlow<BaseItemDto?>(null)
private val _mediaSource = MutableStateFlow<MediaSourceInfo?>(null)
private val _includeRuntime = MutableStateFlow(false)
var item: BaseItemDto?
@@ -372,6 +386,12 @@ class BaseItemInfoRowView @JvmOverloads constructor(
_item.value = value
}
var mediaSource: MediaSourceInfo?
get() = _mediaSource.value
set(value) {
_mediaSource.value = value
}
var includeRuntime: Boolean
get() = _includeRuntime.value
set(value) {
@@ -386,8 +406,9 @@ class BaseItemInfoRowView @JvmOverloads constructor(
@Composable
override fun Content() {
val item by _item.collectAsState()
val mediaSource by _mediaSource.collectAsState()
val includeRuntime by _includeRuntime.collectAsState()
item?.let { BaseItemInfoRow(it, includeRuntime) }
item?.let { BaseItemInfoRow(it, mediaSource, includeRuntime) }
}
}

View File

@@ -7,7 +7,7 @@ import androidx.compose.ui.graphics.Color
*/
object InfoRowColors {
val Transparent = Color.Transparent to Color.White
val Default = Color(0x80FFFFFF) to Color.Black
val Green = Color(0x8034D97C) to Color.Black
val Red = Color(0x80F2364D) to Color.Black
val Default = Color(0xB3FFFFFF) to Color.Black
val Green = Color(0xB3089562) to Color.White
val Red = Color(0xB3F2364D) to Color.White
}

View File

@@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.ProvideTextStyle
@@ -46,6 +47,7 @@ fun InfoRowItem(
value = TextStyle(
color = foregroundColor,
fontSize = if (backgroundColor.alpha > 0f) 12.sp else 16.sp,
fontWeight = if (backgroundColor.alpha > 0f) FontWeight.W600 else FontWeight.W500,
)
) {
Row(

View File

@@ -158,7 +158,7 @@ public class LegacyImageCardView extends BaseCardView {
}
}
public CharSequence gettitle() {
public CharSequence getTitle() {
if (binding.title == null) {
return null;
}
@@ -212,7 +212,7 @@ public class LegacyImageCardView extends BaseCardView {
}
private void setTextMaxLines() {
if (TextUtils.isEmpty(gettitle())) {
if (TextUtils.isEmpty(getTitle())) {
binding.contentText.setMaxLines(2);
} else {
binding.contentText.setMaxLines(1);

View File

@@ -0,0 +1,214 @@
package org.jellyfin.androidtv.ui.composable
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measured
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.tv.material3.LocalTextStyle
import androidx.tv.material3.Text
import org.jellyfin.sdk.model.api.LyricDto
import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration
data class TimedLine(
val timestamp: Duration,
val text: String,
)
private data class LyricsBoxContentMeasurements(
val size: Size,
val items: List<Measured>,
)
private fun Collection<TimedLine>.indexAtTimestamp(timestamp: Duration) = indexOfLast { it.timestamp <= timestamp }.takeIf { it != -1 }
@Composable
private fun LyricsLine(
text: String,
fontSize: TextUnit,
color: Color,
active: Boolean = false,
gap: Dp = 15.dp,
) {
val color by animateColorAsState(
targetValue = if (active) color else color.copy(alpha = 0.5f),
animationSpec = tween(),
label = "LyricsLineColor",
)
val scale by animateFloatAsState(
targetValue = if (active) 1.1f else 1f,
animationSpec = tween(),
label = "LyricsLineScale",
)
Text(
text = text,
textAlign = TextAlign.Center,
fontSize = fontSize,
color = color,
modifier = Modifier
.padding(bottom = gap)
.scale(scale)
)
}
@Composable
private fun <T> LyricsBoxContent(
items: Collection<T>,
modifier: Modifier = Modifier,
onMeasured: ((measurements: LyricsBoxContentMeasurements) -> Unit)? = null,
itemContent: @Composable (item: T, index: Int) -> Unit,
) = Layout(
modifier = modifier,
measurePolicy = { measurables, constraints ->
val placeables = measurables.map { measurable ->
measurable.measure(constraints.copy(minWidth = constraints.maxWidth))
}
if (onMeasured != null) {
val totalHeight = placeables.sumOf { it.height }.toFloat()
onMeasured(LyricsBoxContentMeasurements(Size(1f, totalHeight), placeables))
}
layout(constraints.maxWidth, constraints.maxHeight) {
// Start at the offset until the active line and half the height of this container to vertically center the item
var yOffset = constraints.maxHeight / 2
for (placeable in placeables) {
placeable.placeRelative(
x = 0,
y = yOffset,
)
yOffset += placeable.height
}
}
},
content = {
items.forEachIndexed { index, item ->
itemContent(item, index)
}
},
)
@Composable
fun LyricsBox(
lines: List<TimedLine>,
currentTimestamp: Duration = Duration.ZERO,
fontSize: TextUnit = LocalTextStyle.current.fontSize,
color: Color = LocalTextStyle.current.color,
) {
var lineMeasurements by remember { mutableStateOf<List<Measured>>(emptyList()) }
val activeLine = lines.indexAtTimestamp(currentTimestamp)
val activeLineOffsetAnimation = remember { Animatable(0f) }
LaunchedEffect(activeLine) {
var offset = 0f
if (activeLine != null) {
// Add offset for all previous items
offset += lineMeasurements.take(activeLine).sumOf { it.measuredHeight }
// Add offset for half-height to center item
offset += lineMeasurements.getOrNull(activeLine)?.measuredHeight?.div(2) ?: 0
}
activeLineOffsetAnimation.animateTo(offset)
}
LyricsBoxContent(
items = lines,
modifier = Modifier.graphicsLayer {
translationY = -activeLineOffsetAnimation.value
},
onMeasured = { measurements -> lineMeasurements = measurements.items }
) { line, index ->
LyricsLine(
text = line.text,
active = index == activeLine,
fontSize = fontSize,
color = color,
)
}
}
@Composable
fun LyricsBox(
lines: List<String>,
modifier: Modifier = Modifier,
currentTimestamp: Duration = Duration.ZERO,
duration: Duration = Duration.ZERO,
paused: Boolean = false,
fontSize: TextUnit = LocalTextStyle.current.fontSize,
color: Color = LocalTextStyle.current.color,
) = Box(modifier) {
var totalHeight by remember { mutableFloatStateOf(0f) }
val progress = rememberPlayerProgress(!paused, currentTimestamp, duration)
LyricsBoxContent(
items = lines,
modifier = Modifier.graphicsLayer {
translationY = -(progress * totalHeight)
},
onMeasured = { measurements -> totalHeight = measurements.size.height }
) { line, index ->
LyricsLine(
text = line,
fontSize = fontSize,
color = color,
)
}
}
@Composable
fun LyricsDtoBox(
lyricDto: LyricDto,
modifier: Modifier = Modifier,
currentTimestamp: Duration = Duration.ZERO,
duration: Duration = Duration.ZERO,
paused: Boolean = false,
fontSize: TextUnit = LocalTextStyle.current.fontSize,
color: Color = LocalTextStyle.current.color,
) = Box(modifier) {
val lyrics = lyricDto.lyrics
val isTimed = lyrics.firstOrNull()?.start != null
if (isTimed) {
LyricsBox(
lines = lyrics.map {
TimedLine(it.start?.ticks ?: Duration.ZERO, it.text)
},
currentTimestamp = currentTimestamp,
fontSize = fontSize,
color = color,
)
} else {
LyricsBox(
lines = lyrics.map { it.text },
currentTimestamp = currentTimestamp,
duration = duration,
paused = paused,
fontSize = fontSize,
color = color,
)
}
}

View File

@@ -1,37 +0,0 @@
package org.jellyfin.androidtv.ui.composable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import org.jellyfin.androidtv.ui.playback.AudioEventListener
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.androidtv.ui.playback.PlaybackController
import org.jellyfin.sdk.model.api.BaseItemDto
import org.koin.compose.koinInject
@Composable
fun rememberMediaItem(
mediaManager: MediaManager = koinInject(),
): State<BaseItemDto?> {
val item = remember { mutableStateOf(mediaManager.currentAudioItem) }
DisposableEffect(mediaManager) {
val listener = object : AudioEventListener {
override fun onPlaybackStateChange(newState: PlaybackController.PlaybackState, currentItem: BaseItemDto?) {
item.value = currentItem
}
override fun onQueueStatusChanged(hasQueue: Boolean) {
super.onQueueStatusChanged(hasQueue)
item.value = mediaManager.currentAudioItem
}
}
mediaManager.addAudioEventListener(listener)
onDispose { mediaManager.removeAudioEventListener(listener) }
}
return item
}

View File

@@ -0,0 +1,115 @@
package org.jellyfin.androidtv.ui.composable.modifier
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
fun Modifier.fadingEdges(
all: Dp
) = fadingEdges(
start = all,
top = all,
end = all,
bottom = all,
)
fun Modifier.fadingEdges(
vertical: Dp = 0.dp,
horizontal: Dp = 0.dp,
) = fadingEdges(
start = horizontal,
top = vertical,
end = horizontal,
bottom = vertical,
)
fun Modifier.fadingEdges(
start: Dp = 0.dp,
top: Dp = 0.dp,
end: Dp = 0.dp,
bottom: Dp = 0.dp,
) = then(
Modifier
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
.drawWithContent {
drawContent()
// Start edge
if (start.value > 0) {
drawRect(
brush = Brush.horizontalGradient(
0f to Color.Transparent,
1f to Color.Black,
endX = start.toPx(),
),
blendMode = BlendMode.DstIn,
)
}
// Top edge
if (top.value > 0) {
drawRect(
brush = Brush.verticalGradient(
0f to Color.Transparent,
1f to Color.Black,
endY = top.toPx(),
),
blendMode = BlendMode.DstIn,
)
}
// End edge
if (end.value > 0) {
drawRect(
brush = Brush.horizontalGradient(
0f to Color.Black,
1f to Color.Transparent,
startX = size.width - end.toPx(),
),
blendMode = BlendMode.DstIn,
)
}
// Bottom edge
if (bottom.value > 0) {
drawRect(
brush = Brush.verticalGradient(
0f to Color.Black,
1f to Color.Transparent,
startY = size.height - bottom.toPx(),
),
blendMode = BlendMode.DstIn
)
}
}
)
@Preview
@Composable
private fun FadingEdgePreview() {
Box(
modifier = Modifier
.size(100.dp)
.background(Color.Red)
) {
Box(
modifier = Modifier
.size(50.dp)
.fadingEdges(10.dp, 0.dp, 25.dp, 3.dp)
.background(Color.Blue)
.align(Alignment.Center)
) { }
}
}

View File

@@ -0,0 +1,16 @@
package org.jellyfin.androidtv.ui.composable.modifier
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Default overscan values of 48 horizontal and 27 vertical display pixels.
*/
val overscanPaddingValues = PaddingValues(48.dp, 27.dp)
/**
* Apply a [padding] with [overscanPaddingValues].
*/
fun Modifier.overscan(): Modifier = padding(overscanPaddingValues)

View File

@@ -1,10 +0,0 @@
package org.jellyfin.androidtv.ui.composable
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Apply a [padding] with the default overscan values of 48 horizontal and 27 vertical display pixels.
*/
fun Modifier.overscan(): Modifier = then(padding(48.dp, 27.dp))

View File

@@ -0,0 +1,67 @@
package org.jellyfin.androidtv.ui.composable
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import org.jellyfin.playback.core.PlaybackManager
import org.jellyfin.playback.core.model.PlayState
import org.jellyfin.playback.core.queue.queue
import org.koin.compose.koinInject
import kotlin.math.roundToInt
import kotlin.time.Duration
@Composable
fun rememberQueueEntry(
playbackManager: PlaybackManager = koinInject(),
) = remember(playbackManager) {
playbackManager.queue.entry
}.collectAsState()
@Composable
fun rememberPlayerProgress(
playbackManager: PlaybackManager = koinInject(),
): Float {
val playState by playbackManager.state.playState.collectAsState()
val active = playbackManager.state.positionInfo.active
val duration = playbackManager.state.positionInfo.duration
return rememberPlayerProgress(
playing = playState == PlayState.PLAYING,
active = active,
duration = duration,
)
}
@Composable
fun rememberPlayerProgress(
playing: Boolean,
active: Duration,
duration: Duration,
): Float {
val animatable = remember { Animatable(0f) }
LaunchedEffect(playing, duration) {
val activeMs = active.inWholeMilliseconds.toFloat()
val durationMs = duration.inWholeMilliseconds.toFloat()
if (active == Duration.ZERO) animatable.snapTo(0f)
else animatable.snapTo((activeMs / durationMs).coerceIn(0f, 1f))
if (playing) {
animatable.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = (durationMs - activeMs).roundToInt(),
easing = LinearEasing,
)
)
}
}
return animatable.value
}

View File

@@ -4,9 +4,8 @@ import android.content.Context
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.auth.repository.UserRepository
import org.jellyfin.androidtv.constant.ChangeTriggerType
import org.jellyfin.androidtv.data.querying.GetUserViewsRequest
import org.jellyfin.androidtv.data.repository.UserViewsRepository
import org.jellyfin.androidtv.ui.browsing.BrowseRowDef
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.MediaType
@@ -19,14 +18,9 @@ import org.jellyfin.sdk.model.api.ItemFields as SdkItemFields
class HomeFragmentHelper(
private val context: Context,
private val userRepository: UserRepository,
private val userViewsRepository: UserViewsRepository,
) {
fun loadRecentlyAdded(): HomeFragmentRow {
return HomeFragmentLatestRow(userRepository, userViewsRepository)
}
fun loadLibraryTiles(): HomeFragmentRow {
return HomeFragmentBrowseRowDefRow(BrowseRowDef(context.getString(R.string.lbl_my_media), GetUserViewsRequest))
fun loadRecentlyAdded(userViews: Collection<BaseItemDto>): HomeFragmentRow {
return HomeFragmentLatestRow(userRepository, userViews)
}
fun loadResume(title: String, includeMediaTypes: Collection<MediaType>): HomeFragmentRow {

View File

@@ -2,22 +2,20 @@ package org.jellyfin.androidtv.ui.home
import android.content.Context
import androidx.leanback.widget.Row
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.auth.repository.UserRepository
import org.jellyfin.androidtv.constant.ChangeTriggerType
import org.jellyfin.androidtv.data.repository.UserViewsRepository
import org.jellyfin.androidtv.ui.browsing.BrowseRowDef
import org.jellyfin.androidtv.ui.presentation.CardPresenter
import org.jellyfin.androidtv.ui.presentation.MutableObjectAdapter
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
class HomeFragmentLatestRow(
private val userRepository: UserRepository,
private val userViewsRepository: UserViewsRepository,
private val userViews: Collection<BaseItemDto>,
) : HomeFragmentRow {
override fun addToRowsAdapter(context: Context, cardPresenter: CardPresenter, rowsAdapter: MutableObjectAdapter<Row>) {
// Get configuration (to find excluded items)
@@ -25,8 +23,7 @@ class HomeFragmentLatestRow(
// Create a list of views to include
val latestItemsExcludes = configuration?.latestItemsExcludes.orEmpty()
val views = runBlocking { userViewsRepository.views.first() }
views
userViews
.filterNot { item -> item.collectionType in EXCLUDED_COLLECTION_TYPES || item.id in latestItemsExcludes }
.map { item ->
// Create query and add it to a new row
@@ -52,7 +49,7 @@ class HomeFragmentLatestRow(
}
companion object {
// Collections exclused from latest row based on app support and common sense
// Collections excluded from latest row based on app support and common sense
private val EXCLUDED_COLLECTION_TYPES = arrayOf(
CollectionType.PLAYLISTS,
CollectionType.LIVETV,
@@ -60,7 +57,7 @@ class HomeFragmentLatestRow(
CollectionType.BOOKS,
)
// Maximum ammount of items loaded for a row
// Maximum amount of items loaded for a row
private const val ITEM_LIMIT = 50
}
}

View File

@@ -0,0 +1,32 @@
package org.jellyfin.androidtv.ui.home
import android.content.Context
import androidx.leanback.widget.HeaderItem
import androidx.leanback.widget.ListRow
import androidx.leanback.widget.Row
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.data.querying.GetUserViewsRequest
import org.jellyfin.androidtv.ui.itemhandling.ItemRowAdapter
import org.jellyfin.androidtv.ui.presentation.CardPresenter
import org.jellyfin.androidtv.ui.presentation.MutableObjectAdapter
import org.jellyfin.androidtv.ui.presentation.UserViewCardPresenter
class HomeFragmentViewsRow(
val small: Boolean,
) : HomeFragmentRow {
private companion object {
val smallCardPresenter = UserViewCardPresenter(true)
val largeCardPresenter = UserViewCardPresenter(false)
}
override fun addToRowsAdapter(context: Context, cardPresenter: CardPresenter, rowsAdapter: MutableObjectAdapter<Row>) {
val presenter = if (small) smallCardPresenter else largeCardPresenter
val rowAdapter = ItemRowAdapter(context, GetUserViewsRequest, presenter, rowsAdapter)
val header = HeaderItem(context.getString(R.string.lbl_my_media))
val row = ListRow(header, rowAdapter)
rowAdapter.setRow(row)
rowAdapter.Retrieve()
rowsAdapter.add(row)
}
}

View File

@@ -32,7 +32,6 @@ import org.jellyfin.androidtv.data.repository.CustomMessageRepository
import org.jellyfin.androidtv.data.repository.NotificationsRepository
import org.jellyfin.androidtv.data.repository.UserViewsRepository
import org.jellyfin.androidtv.data.service.BackgroundService
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.preference.UserSettingPreferences
import org.jellyfin.androidtv.ui.browsing.CompositeClickedListener
import org.jellyfin.androidtv.ui.browsing.CompositeSelectedListener
@@ -62,7 +61,6 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
private val mediaManager by inject<MediaManager>()
private val notificationsRepository by inject<NotificationsRepository>()
private val userRepository by inject<UserRepository>()
private val userPreferences by inject<UserPreferences>()
private val userSettingPreferences by inject<UserSettingPreferences>()
private val userViewsRepository by inject<UserViewsRepository>()
private val dataRefreshService by inject<DataRefreshService>()
@@ -71,7 +69,7 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
private val itemLauncher by inject<ItemLauncher>()
private val keyProcessor by inject<KeyProcessor>()
private val helper by lazy { HomeFragmentHelper(requireContext(), userRepository, userViewsRepository) }
private val helper by lazy { HomeFragmentHelper(requireContext(), userRepository) }
// Data
private var currentItem: BaseRowItem? = null
@@ -94,7 +92,7 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
}
// Start out with default sections
val homesections = userSettingPreferences.homesections
val homesections = userSettingPreferences.activeHomesections
var includeLiveTvRows = false
// Check for live TV support
@@ -107,7 +105,7 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
isAiring = true,
limit = 1,
)
includeLiveTvRows = !recommendedPrograms.items.isNullOrEmpty()
includeLiveTvRows = recommendedPrograms.items.isNotEmpty()
}
// Make sure the rows are empty
@@ -118,9 +116,9 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
// Actually add the sections
for (section in homesections) when (section) {
HomeSectionType.LATEST_MEDIA -> rows.add(helper.loadRecentlyAdded())
HomeSectionType.LIBRARY_TILES_SMALL -> rows.add(helper.loadLibraryTiles())
HomeSectionType.LIBRARY_BUTTONS -> rows.add(helper.loadLibraryTiles())
HomeSectionType.LATEST_MEDIA -> rows.add(helper.loadRecentlyAdded(userViewsRepository.views.first()))
HomeSectionType.LIBRARY_TILES_SMALL -> rows.add(HomeFragmentViewsRow(small = false))
HomeSectionType.LIBRARY_BUTTONS -> rows.add(HomeFragmentViewsRow(small = true))
HomeSectionType.RESUME -> rows.add(helper.loadResumeVideo())
HomeSectionType.RESUME_AUDIO -> rows.add(helper.loadResumeAudio())
HomeSectionType.RESUME_BOOK -> Unit // Books are not (yet) supported
@@ -166,15 +164,13 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
if (userPreferences[UserPreferences.homeReactive]) {
api.webSocket.subscribe<UserDataChangedMessage>()
.onEach { refreshRows(force = true, delayed = false) }
.launchIn(this)
api.webSocket.subscribe<UserDataChangedMessage>()
.onEach { refreshRows(force = true, delayed = false) }
.launchIn(this)
api.webSocket.subscribe<LibraryChangedMessage>()
.onEach { refreshRows(force = true, delayed = false) }
.launchIn(this)
}
api.webSocket.subscribe<LibraryChangedMessage>()
.onEach { refreshRows(force = true, delayed = false) }
.launchIn(this)
}
}

View File

@@ -709,7 +709,7 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
}
});
} else {
mediaManager.getValue().addToAudioQueue(Arrays.asList(baseItem));
mediaManager.getValue().queueAudioItem(baseItem);
}
}
}
@@ -1186,6 +1186,10 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
@Override
public void onResponse(List<BaseItemDto> response) {
if (!getActive()) return;
if (response.isEmpty()) {
Timber.e("No items to play - ignoring play request.");
return;
}
if (item.getType() == BaseItemKind.MUSIC_ARTIST) {
mediaManager.getValue().playNow(requireContext(), response, 0, shuffle);
@@ -1196,7 +1200,6 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
}
}
});
}
void play(final List<BaseItemDto> items, final int pos, final boolean shuffle) {

View File

@@ -28,6 +28,7 @@ import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemFilter
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.MediaType
@@ -241,6 +242,12 @@ fun FullDetailsFragment.resumePlayback() {
includeItemTypes = setOf(BaseItemKind.EPISODE),
recursive = true,
filters = setOf(ItemFilter.IS_UNPLAYED),
fields = setOf(
ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_STREAMS,
ItemFields.CHAPTERS,
ItemFields.TRICKPLAY,
),
sortBy = setOf(
ItemSortBy.PARENT_INDEX_NUMBER,
ItemSortBy.INDEX_NUMBER,

View File

@@ -1,6 +1,7 @@
package org.jellyfin.androidtv.ui.itemdetail
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.data.repository.ItemMutationRepository
import org.jellyfin.sdk.api.client.ApiClient
@@ -29,7 +30,7 @@ fun ItemListFragment.loadItem(itemId: UUID) {
lifecycleScope.launch {
val item by api.userLibraryApi.getItem(itemId)
setBaseItem(item)
if (isActive) setBaseItem(item)
}
}
@@ -50,7 +51,7 @@ fun MusicFavoritesListFragment.getFavoritePlaylist(
fields = itemFields,
)
callback(result.items.orEmpty())
callback(result.items)
}
}
@@ -78,7 +79,7 @@ fun ItemListFragment.getPlaylist(
)
}
callback(result.items.orEmpty())
callback(result.items)
}
}
@@ -88,7 +89,7 @@ fun ItemListFragment.toggleFavorite(item: BaseItemDto, callback: (item: BaseItem
lifecycleScope.launch {
val userData = itemMutationRepository.setFavorite(
item = item.id,
favorite = item.userData?.isFavorite ?: true
favorite = !(item.userData?.isFavorite ?: false)
)
callback(item.copy(userData = userData))
}

View File

@@ -23,7 +23,6 @@ open class BaseItemDtoBaseRowItem @JvmOverloads constructor(
val preferSeriesPoster: Boolean = false,
) : BaseRowItem(
baseRowType = when (item.type) {
BaseItemKind.CHANNEL,
BaseItemKind.TV_CHANNEL,
BaseItemKind.LIVE_TV_CHANNEL -> BaseRowType.LiveTvChannel
@@ -80,6 +79,8 @@ open class BaseItemDtoBaseRowItem @JvmOverloads constructor(
}
override fun getCardName(context: Context) = when {
baseItem?.type == BaseItemKind.AUDIO && baseItem.artists != null -> baseItem.artists?.joinToString(", ")
baseItem?.type == BaseItemKind.AUDIO && baseItem.albumArtists != null -> baseItem.albumArtists?.joinToString(", ")
baseItem?.type == BaseItemKind.AUDIO && baseItem.albumArtist != null -> baseItem.albumArtist
baseItem?.type == BaseItemKind.AUDIO && baseItem.album != null -> baseItem.album
else -> baseItem?.getFullName(context)

View File

@@ -77,7 +77,7 @@ fun ItemRowAdapter.retrieveResumeItems(api: ApiClient, query: GetResumeItemsRequ
val response by api.itemsApi.getResumeItems(query)
setItems(
items = response.items.orEmpty(),
items = response.items,
transform = { item, _ ->
BaseItemDtoBaseRowItem(
item,
@@ -87,7 +87,7 @@ fun ItemRowAdapter.retrieveResumeItems(api: ApiClient, query: GetResumeItemsRequ
}
)
if (response.items.isNullOrEmpty()) removeRow()
if (response.items.isEmpty()) removeRow()
}.fold(
onSuccess = { notifyRetrieveFinished() },
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
@@ -101,8 +101,8 @@ fun ItemRowAdapter.retrieveNextUpItems(api: ApiClient, query: GetNextUpRequest)
val response by api.tvShowsApi.getNextUp(query)
// Some special flavor for series, used in FullDetailsFragment
val firstNextUp = response.items?.firstOrNull()
if (query.seriesId != null && response.items?.size == 1 && firstNextUp?.seasonId != null && firstNextUp.indexNumber != null) {
val firstNextUp = response.items.firstOrNull()
if (query.seriesId != null && response.items.size == 1 && firstNextUp?.seasonId != null && firstNextUp.indexNumber != null) {
// If we have exactly 1 episode returned, the series is currently partially watched
// we want to query the server for all episodes in the same season starting from
// this one to create a list of all unwatched episodes
@@ -114,7 +114,7 @@ fun ItemRowAdapter.retrieveNextUpItems(api: ApiClient, query: GetNextUpRequest)
// Combine the next up episode with the additionally retrieved episodes
val items = buildList {
add(firstNextUp)
episodesResponse.items?.let { addAll(it) }
addAll(episodesResponse.items)
}
setItems(
@@ -131,7 +131,7 @@ fun ItemRowAdapter.retrieveNextUpItems(api: ApiClient, query: GetNextUpRequest)
if (items.isEmpty()) removeRow()
} else {
setItems(
items = response.items.orEmpty(),
items = response.items,
transform = { item, _ ->
BaseItemDtoBaseRowItem(
item,
@@ -141,7 +141,7 @@ fun ItemRowAdapter.retrieveNextUpItems(api: ApiClient, query: GetNextUpRequest)
}
)
if (response.items.isNullOrEmpty()) removeRow()
if (response.items.isEmpty()) removeRow()
}
}.fold(
onSuccess = { notifyRetrieveFinished() },
@@ -202,11 +202,11 @@ fun ItemRowAdapter.retrieveAdditionalParts(api: ApiClient, query: GetAdditionalP
val response by api.videosApi.getAdditionalPart(query.itemId)
setItems(
items = response.items.orEmpty(),
items = response.items,
transform = { item, _ -> BaseItemDtoBaseRowItem(item) }
)
if (response.items.isNullOrEmpty()) removeRow()
if (response.items.isEmpty()) removeRow()
}.fold(
onSuccess = { notifyRetrieveFinished() },
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
@@ -219,7 +219,7 @@ fun ItemRowAdapter.retrieveUserViews(api: ApiClient, userViewsRepository: UserVi
runCatching {
val response by api.userViewsApi.getUserViews()
val filteredItems = response.items.orEmpty()
val filteredItems = response.items
.filter { userViewsRepository.isSupported(it.collectionType) }
.map { it.copy(displayPreferencesId = it.id.toString()) }
@@ -242,11 +242,11 @@ fun ItemRowAdapter.retrieveSeasons(api: ApiClient, query: GetSeasonsRequest) {
val response by api.tvShowsApi.getSeasons(query)
setItems(
items = response.items.orEmpty(),
items = response.items,
transform = { item, _ -> BaseItemDtoBaseRowItem(item) }
)
if (response.items.isNullOrEmpty()) removeRow()
if (response.items.isEmpty()) removeRow()
}.fold(
onSuccess = { notifyRetrieveFinished() },
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
@@ -260,11 +260,11 @@ fun ItemRowAdapter.retrieveUpcomingEpisodes(api: ApiClient, query: GetUpcomingEp
val response by api.tvShowsApi.getUpcomingEpisodes(query)
setItems(
items = response.items.orEmpty(),
items = response.items,
transform = { item, _ -> BaseItemDtoBaseRowItem(item) }
)
if (response.items.isNullOrEmpty()) removeRow()
if (response.items.isEmpty()) removeRow()
}.fold(
onSuccess = { notifyRetrieveFinished() },
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
@@ -278,11 +278,11 @@ fun ItemRowAdapter.retrieveSimilarItems(api: ApiClient, query: GetSimilarItemsRe
val response by api.libraryApi.getSimilarItems(query)
setItems(
items = response.items.orEmpty(),
items = response.items,
transform = { item, _ -> BaseItemDtoBaseRowItem(item) }
)
if (response.items.isNullOrEmpty()) removeRow()
if (response.items.isEmpty()) removeRow()
}.fold(
onSuccess = { notifyRetrieveFinished() },
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
@@ -325,7 +325,7 @@ fun ItemRowAdapter.retrieveLiveTvRecommendedPrograms(
val response by api.liveTvApi.getRecommendedPrograms(query)
setItems(
items = response.items.orEmpty(),
items = response.items,
transform = { item, _ ->
BaseItemDtoBaseRowItem(
item,
@@ -335,7 +335,7 @@ fun ItemRowAdapter.retrieveLiveTvRecommendedPrograms(
}
)
if (response.items.isNullOrEmpty()) removeRow()
if (response.items.isEmpty()) removeRow()
}.fold(
onSuccess = { notifyRetrieveFinished() },
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
@@ -349,7 +349,7 @@ fun ItemRowAdapter.retrieveLiveTvRecordings(api: ApiClient, query: GetRecordings
val response by api.liveTvApi.getRecordings(query)
setItems(
items = response.items.orEmpty(),
items = response.items,
transform = { item, _ ->
BaseItemDtoBaseRowItem(
item,
@@ -359,7 +359,7 @@ fun ItemRowAdapter.retrieveLiveTvRecordings(api: ApiClient, query: GetRecordings
}
)
if (response.items.isNullOrEmpty()) removeRow()
if (response.items.isEmpty()) removeRow()
}.fold(
onSuccess = { notifyRetrieveFinished() },
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
@@ -401,7 +401,7 @@ fun ItemRowAdapter.retrieveLiveTvSeriesTimers(
)
}
addAll(response.items.orEmpty())
addAll(response.items)
},
transform = { item, _ ->
when (item) {
@@ -412,7 +412,7 @@ fun ItemRowAdapter.retrieveLiveTvSeriesTimers(
}
)
if (response.items.isNullOrEmpty()) removeRow()
if (response.items.isEmpty()) removeRow()
}.fold(
onSuccess = { notifyRetrieveFinished() },
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
@@ -437,7 +437,7 @@ fun ItemRowAdapter.retrieveLiveTvChannels(
totalItems = response.totalRecordCount
setItems(
items = response.items.orEmpty(),
items = response.items,
transform = { item, _ ->
BaseItemDtoBaseRowItem(
item,
@@ -447,7 +447,7 @@ fun ItemRowAdapter.retrieveLiveTvChannels(
},
)
if (response.items.isNullOrEmpty()) removeRow()
if (response.items.isEmpty()) removeRow()
}.fold(
onSuccess = { notifyRetrieveFinished() },
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
@@ -472,7 +472,7 @@ fun ItemRowAdapter.retrieveAlbumArtists(
totalItems = response.totalRecordCount
setItems(
items = response.items.orEmpty(),
items = response.items,
transform = { item, _ ->
BaseItemDtoBaseRowItem(
item,
@@ -482,7 +482,7 @@ fun ItemRowAdapter.retrieveAlbumArtists(
},
)
if (response.items.isNullOrEmpty()) removeRow()
if (response.items.isEmpty()) removeRow()
}.fold(
onSuccess = { notifyRetrieveFinished() },
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
@@ -507,7 +507,7 @@ fun ItemRowAdapter.retrieveArtists(
totalItems = response.totalRecordCount
setItems(
items = response.items.orEmpty(),
items = response.items,
transform = { item, _ ->
BaseItemDtoBaseRowItem(
item,
@@ -517,7 +517,7 @@ fun ItemRowAdapter.retrieveArtists(
},
)
if (response.items.isNullOrEmpty()) removeRow()
if (response.items.isEmpty()) removeRow()
}.fold(
onSuccess = { notifyRetrieveFinished() },
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
@@ -542,7 +542,7 @@ fun ItemRowAdapter.retrieveItems(
totalItems = response.totalRecordCount
setItems(
items = response.items.orEmpty(),
items = response.items,
transform = { item, _ ->
BaseItemDtoBaseRowItem(
item,
@@ -552,7 +552,7 @@ fun ItemRowAdapter.retrieveItems(
},
)
if (response.items.isNullOrEmpty()) removeRow()
if (response.items.isEmpty()) removeRow()
}.fold(
onSuccess = { notifyRetrieveFinished() },
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
@@ -567,12 +567,9 @@ fun ItemRowAdapter.retrievePremieres(
ProcessLifecycleOwner.get().lifecycleScope.launch {
runCatching {
val response by api.itemsApi.getItems(query)
val filteredItems = response.items
.orEmpty()
.filter { it.indexNumber == 1 }
setItems(
items = filteredItems,
items = response.items,
transform = { item, _ ->
BaseItemDtoBaseRowItem(
item,
@@ -582,7 +579,7 @@ fun ItemRowAdapter.retrievePremieres(
}
)
if (filteredItems.isEmpty()) removeRow()
if (response.items.isEmpty()) removeRow()
}.fold(
onSuccess = { notifyRetrieveFinished() },
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }

View File

@@ -538,8 +538,8 @@ public class LiveTvGuideFragment extends Fragment implements LiveTvGuide, View.O
// set focus parameters if we are not on first row
// this makes focus movements more predictable for the grid view
if (prevRow != null) {
TvManager.setFocusParms(row, prevRow, true);
TvManager.setFocusParms(prevRow, row, false);
TvManager.setFocusParams(row, prevRow, true);
TvManager.setFocusParams(prevRow, row, false);
}
prevRow = row;

View File

@@ -204,7 +204,7 @@ public class TvManager {
}
// this makes focus movements more predictable for the grid view
public static void setFocusParms(LinearLayout currentRow, LinearLayout otherRow, boolean up) {
public static void setFocusParams(LinearLayout currentRow, LinearLayout otherRow, boolean up) {
for (int currentRowNdx = 0; currentRowNdx < currentRow.getChildCount(); currentRowNdx++) {
ProgramGridCell cell = (ProgramGridCell) currentRow.getChildAt(currentRowNdx);
ProgramGridCell otherCell = getOtherCell(otherRow, cell);

View File

@@ -40,7 +40,7 @@ fun loadLiveTvChannels(fragment: Fragment, callback: (channels: Collection<BaseI
enableFavoriteSorting = liveTvPreferences[LiveTvPreferences.favsAtTop],
sortBy = if (sortDatePlayed) setOf(ItemSortBy.DATE_PLAYED) else setOf(ItemSortBy.SORT_NAME),
sortOrder = if (sortDatePlayed) SortOrder.DESCENDING else SortOrder.ASCENDING,
).content.items.orEmpty()
).content.items
}.fold(
onSuccess = { channels -> callback(channels) },
onFailure = { callback(null) },
@@ -84,7 +84,7 @@ fun getScheduleRows(
runCatching {
api.liveTvApi.getTimers(
seriesTimerId = seriesTimerId,
).content.items.orEmpty()
).content.items
}.fold(
onSuccess = { timers ->
val groupedTimers = timers

View File

@@ -15,7 +15,7 @@ sealed interface NavigationAction {
) : NavigationAction
/**
* Open the activity in [destination] and immediatly call [onOpened] to clear the emitted state.
* Open the activity in [destination] and immediately call [onOpened] to clear the emitted state.
*/
data class NavigateActivity(
val destination: Destination.Activity,

View File

@@ -60,20 +60,13 @@ interface NavigationRepository {
}
class NavigationRepositoryImpl(
private val initialDestination: Destination.Fragment,
private val defaultDestination: Destination.Fragment,
) : NavigationRepository {
private val fragmentHistory = Stack<Destination.Fragment>()
private val _currentAction = MutableSharedFlow<NavigationAction>(1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override val currentAction = _currentAction.asSharedFlow()
init {
// Never add the initial destination to the history to prevent an empty screen when the user
// uses the "back" button to close the app
_currentAction.tryEmit(NavigationAction.NavigateFragment(initialDestination, false, false, false))
Timber.d("Navigating to $initialDestination (via init)")
}
override fun navigate(destination: Destination, replace: Boolean) {
Timber.d("Navigating to $destination (via navigate function)")
val action = when (destination) {
@@ -83,11 +76,11 @@ class NavigationRepositoryImpl(
_currentAction.tryEmit(NavigationAction.Nothing)
}
}
_currentAction.tryEmit(action)
if (destination is Destination.Fragment) {
if (replace && fragmentHistory.isNotEmpty()) fragmentHistory[fragmentHistory.lastIndex] = destination
else fragmentHistory.push(destination)
}
_currentAction.tryEmit(action)
}
override val canGoBack: Boolean get() = fragmentHistory.isNotEmpty()
@@ -103,8 +96,8 @@ class NavigationRepositoryImpl(
override fun reset(destination: Destination.Fragment?, clearHistory: Boolean) {
fragmentHistory.clear()
val actualDestination = destination ?: initialDestination
_currentAction.tryEmit(NavigationAction.NavigateFragment(actualDestination, false, false, clearHistory))
val actualDestination = destination ?: defaultDestination
_currentAction.tryEmit(NavigationAction.NavigateFragment(actualDestination, true, false, clearHistory))
Timber.d("Navigating to $actualDestination (via reset, clearHistory=$clearHistory)")
}
}

View File

@@ -201,7 +201,7 @@ class PictureViewerFragment : Fragment(), View.OnKeyListener {
actionHideTimer = lifecycleScope.launch {
delay(AUTO_HIDE_ACTIONS_DURATION)
// Only auto-hide when there is an active presenation
// Only auto-hide when there is an active presentation
if (pictureViewerViewModel.presentationActive.value) hideActions()
}
}

View File

@@ -39,7 +39,7 @@ class PictureViewerViewModel(private val api: ApiClient) : ViewModel() {
sortBy = sortBy,
sortOrder = listOf(sortOrder),
)
album = albumResponse.items.orEmpty()
album = albumResponse.items
albumIndex = album.indexOfFirst { it.id == id }
// In some rare cases the album of the image might be empty when the
@@ -53,6 +53,8 @@ class PictureViewerViewModel(private val api: ApiClient) : ViewModel() {
// Album actions
fun showNext() {
if (album.isEmpty()) return
albumIndex++
if (albumIndex == album.size) albumIndex = 0
@@ -61,6 +63,8 @@ class PictureViewerViewModel(private val api: ApiClient) : ViewModel() {
}
fun showPrevious() {
if (album.isEmpty()) return
albumIndex--
if (albumIndex == -1) albumIndex = album.size - 1

View File

@@ -42,6 +42,7 @@ import org.jellyfin.androidtv.util.ImageHelper;
import org.jellyfin.androidtv.util.KeyProcessor;
import org.jellyfin.androidtv.util.TimeUtils;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.playback.core.PlaybackManager;
import java.util.List;
@@ -84,6 +85,7 @@ public class AudioNowPlayingFragment extends Fragment {
private final Lazy<BackgroundService> backgroundService = inject(BackgroundService.class);
private final Lazy<MediaManager> mediaManager = inject(MediaManager.class);
private final Lazy<PlaybackManager> playbackManager = inject(PlaybackManager.class);
private final Lazy<NavigationRepository> navigationRepository = inject(NavigationRepository.class);
private final Lazy<KeyProcessor> keyProcessor = inject(KeyProcessor.class);
private final Lazy<ImageHelper> imageHelper = inject(ImageHelper.class);
@@ -107,6 +109,7 @@ public class AudioNowPlayingFragment extends Fragment {
mCurrentNdx = binding.track;
mScrollView = binding.mainScroller;
mCounter = binding.counter;
AudioNowPlayingFragmentHelperKt.initializeLyricsView(binding.poster, binding.lyrics, playbackManager.getValue());
mPlayPauseButton = binding.playPauseBtn;
mPlayPauseButton.setContentDescription(getString(R.string.lbl_pause));
@@ -144,7 +147,7 @@ public class AudioNowPlayingFragment extends Fragment {
@Override
public void onClick(View v) {
mediaManager.getValue().toggleRepeat();
updateButtons(mediaManager.getValue().isPlayingAudio());
updateButtons();
}
});
mRepeatButton.setOnFocusChangeListener(mainAreaFocusListener);
@@ -155,7 +158,7 @@ public class AudioNowPlayingFragment extends Fragment {
@Override
public void onClick(View v) {
mediaManager.getValue().shuffleAudioQueue();
updateButtons(mediaManager.getValue().isPlayingAudio());
updateButtons();
}
});
mShuffleButton.setOnFocusChangeListener(mainAreaFocusListener);
@@ -175,7 +178,7 @@ public class AudioNowPlayingFragment extends Fragment {
mArtistButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (mBaseItem.getAlbumArtists() != null && mBaseItem.getAlbumArtists().size() > 0) {
if (mBaseItem.getAlbumArtists() != null && !mBaseItem.getAlbumArtists().isEmpty()) {
navigationRepository.getValue().navigate(Destinations.INSTANCE.itemDetails(mBaseItem.getAlbumArtists().get(0).getId()));
}
}
@@ -217,10 +220,10 @@ public class AudioNowPlayingFragment extends Fragment {
@Override
public void onResume() {
super.onResume();
loadItem();
//link events
mediaManager.getValue().addAudioEventListener(audioEventListener);
updateButtons(mediaManager.getValue().isPlayingAudio());
loadItem();
updateButtons();
// load the item duration and set the position to 0 since it won't be set elsewhere until playback is initialized
if (!mediaManager.getValue().isAudioPlayerInitialized())
@@ -238,13 +241,8 @@ public class AudioNowPlayingFragment extends Fragment {
@Override
public void onPlaybackStateChange(@NonNull PlaybackController.PlaybackState newState, @Nullable org.jellyfin.sdk.model.api.BaseItemDto currentItem) {
Timber.d("**** Got playstate change: %s", newState.toString());
if (newState == PlaybackController.PlaybackState.PLAYING && currentItem != mBaseItem) {
// new item started
loadItem();
updateButtons(true);
} else {
updateButtons(newState == PlaybackController.PlaybackState.PLAYING);
}
if (currentItem != mBaseItem) loadItem();
updateButtons();
}
@Override
@@ -257,11 +255,11 @@ public class AudioNowPlayingFragment extends Fragment {
@Override
public void onQueueStatusChanged(boolean hasQueue) {
Timber.d("Queue status changed");
Timber.d("Queue status changed (hasQueue=%s)", hasQueue);
if (hasQueue) {
loadItem();
if (mediaManager.getValue().isAudioPlayerInitialized()) {
updateButtons(mediaManager.getValue().isPlayingAudio());
updateButtons();
}
} else {
if (navigationRepository.getValue().getCanGoBack()) navigationRepository.getValue().goBack();
@@ -313,8 +311,9 @@ public class AudioNowPlayingFragment extends Fragment {
}
}
private void updateButtons(final boolean playing) {
private void updateButtons() {
Timber.d("Updating buttons");
boolean playing = mediaManager.getValue().isPlayingAudio();
requireActivity().runOnUiThread(new Runnable() {
@Override
public void run() {
@@ -334,14 +333,14 @@ public class AudioNowPlayingFragment extends Fragment {
mShuffleButton.setActivated(mediaManager.getValue().isShuffleMode());
if (mBaseItem != null) {
mAlbumButton.setEnabled(mBaseItem.getAlbumId() != null);
mArtistButton.setEnabled(mBaseItem.getAlbumArtists() != null && mBaseItem.getAlbumArtists().size() > 0);
mArtistButton.setEnabled(mBaseItem.getAlbumArtists() != null && !mBaseItem.getAlbumArtists().isEmpty());
}
}
});
}
private String getArtistName(org.jellyfin.sdk.model.api.BaseItemDto item) {
String artistName = item.getArtists() != null && item.getArtists().size() > 0 ? item.getArtists().get(0) : item.getAlbumArtist();
String artistName = item.getArtists() != null && !item.getArtists().isEmpty() ? item.getArtists().get(0) : item.getAlbumArtist();
return artistName != null ? artistName : "";
}
@@ -365,7 +364,7 @@ public class AudioNowPlayingFragment extends Fragment {
public void setCurrentTime(long time) {
// Round the current time as otherwise the time played and time remaining will not be in sync
time = Math.round(time / 1000) * 1000;
time = Math.round(time / 1000L) * 1000L;
mCurrentProgress.setProgress(((Long) time).intValue());
mCurrentPos.setText(TimeUtils.formatMillis(time));
mRemainingTime.setText(mCurrentDuration > 0 ? "-" + TimeUtils.formatMillis(mCurrentDuration - time) : "");

View File

@@ -0,0 +1,63 @@
package org.jellyfin.androidtv.ui.playback
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.jellyfin.androidtv.ui.AsyncImageView
import org.jellyfin.androidtv.ui.composable.LyricsDtoBox
import org.jellyfin.androidtv.ui.composable.modifier.fadingEdges
import org.jellyfin.androidtv.ui.composable.rememberPlayerProgress
import org.jellyfin.androidtv.ui.composable.rememberQueueEntry
import org.jellyfin.playback.core.PlaybackManager
import org.jellyfin.playback.core.model.PlayState
import org.jellyfin.playback.jellyfin.lyrics
import org.jellyfin.playback.jellyfin.lyricsFlow
fun initializeLyricsView(
coverView: AsyncImageView,
lyricsView: ComposeView,
playbackManager: PlaybackManager,
) {
lyricsView.setContent {
val entry by rememberQueueEntry(playbackManager)
val lyrics = entry?.run { lyricsFlow.collectAsState(lyrics) }?.value
// Animate cover view alpha
val coverViewAlpha by animateFloatAsState(
label = "CoverViewAlpha",
targetValue = if (lyrics == null) 1f else 0.2f,
)
LaunchedEffect(coverViewAlpha) { coverView.alpha = coverViewAlpha }
// Display lyrics overlay
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 = 12.sp,
color = Color.White,
modifier = Modifier
.fillMaxSize()
.fadingEdges(vertical = 50.dp)
.padding(horizontal = 15.dp),
)
}
}
}

View File

@@ -7,15 +7,14 @@ import android.content.Context;
import android.graphics.Color;
import android.media.AudioManager;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.text.Spannable;
import android.text.SpannableString;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.LinearLayout;
@@ -25,7 +24,6 @@ import android.widget.TextView;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.fragment.app.Fragment;
@@ -45,7 +43,6 @@ import org.jellyfin.androidtv.data.repository.CustomMessageRepository;
import org.jellyfin.androidtv.data.service.BackgroundService;
import org.jellyfin.androidtv.databinding.OverlayTvGuideBinding;
import org.jellyfin.androidtv.databinding.VlcPlayerInterfaceBinding;
import org.jellyfin.androidtv.preference.UserPreferences;
import org.jellyfin.androidtv.ui.GuideChannelHeader;
import org.jellyfin.androidtv.ui.GuidePagingButton;
import org.jellyfin.androidtv.ui.HorizontalScrollViewListener;
@@ -67,7 +64,6 @@ import org.jellyfin.androidtv.ui.presentation.CardPresenter;
import org.jellyfin.androidtv.ui.presentation.ChannelCardPresenter;
import org.jellyfin.androidtv.ui.presentation.MutableObjectAdapter;
import org.jellyfin.androidtv.ui.presentation.PositionableListRowPresenter;
import org.jellyfin.androidtv.ui.shared.PaddedLineBackgroundSpan;
import org.jellyfin.androidtv.util.CoroutineUtils;
import org.jellyfin.androidtv.util.DateTimeExtensionsKt;
import org.jellyfin.androidtv.util.ImageHelper;
@@ -77,12 +73,9 @@ import org.jellyfin.androidtv.util.TimeUtils;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.androidtv.util.apiclient.EmptyLifecycleAwareResponse;
import org.jellyfin.androidtv.util.sdk.BaseItemExtensionsKt;
import org.jellyfin.apiclient.model.mediainfo.SubtitleTrackEvent;
import org.jellyfin.apiclient.model.mediainfo.SubtitleTrackInfo;
import org.jellyfin.sdk.model.api.BaseItemDto;
import org.jellyfin.sdk.model.api.BaseItemKind;
import org.jellyfin.sdk.model.api.ChapterInfo;
import org.koin.java.KoinJavaComponent;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
@@ -93,7 +86,7 @@ import kotlin.Lazy;
import timber.log.Timber;
public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGuide, View.OnKeyListener {
private VlcPlayerInterfaceBinding binding;
protected VlcPlayerInterfaceBinding binding;
private OverlayTvGuideBinding tvGuideBinding;
private RowsSupportFragment mPopupRowsFragment;
@@ -130,20 +123,9 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
private boolean mFadeEnabled = false;
private boolean mIsVisible = false;
private boolean mPopupPanelVisible = false;
private boolean navigating = false;
private LeanbackOverlayFragment leanbackOverlayFragment;
// Subtitle fields
private static final int SUBTITLE_PADDING = 20;
private static final long SUBTITLE_RENDER_INTERVAL_MS = 50;
private SubtitleTrackInfo subtitleTrackInfo;
private int currentSubtitleIndex = 0;
private long lastSubtitlePositionMs = 0;
private final UserPreferences userPreferences = KoinJavaComponent.<UserPreferences>get(UserPreferences.class);
private final int subtitlesSize = userPreferences.get(UserPreferences.Companion.getSubtitlesSize());
private final boolean subtitlesBackgroundEnabled = userPreferences.get(UserPreferences.Companion.getSubtitlesBackgroundEnabled());
private final int subtitlesPosition = userPreferences.get(UserPreferences.Companion.getSubtitlePosition());
private final int subtitlesStrokeWidth = userPreferences.get(UserPreferences.Companion.getSubtitleStrokeSize());
protected LeanbackOverlayFragment leanbackOverlayFragment;
private final Lazy<org.jellyfin.sdk.api.client.ApiClient> api = inject(org.jellyfin.sdk.api.client.ApiClient.class);
private final Lazy<MediaManager> mediaManager = inject(MediaManager.class);
@@ -173,11 +155,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
requireActivity().setVolumeControlStream(AudioManager.STREAM_MUSIC);
mItemsToPlay = videoQueueManager.getValue().getCurrentVideoQueue();
if (mItemsToPlay == null || mItemsToPlay.size() == 0) {
Utils.showToast(requireContext(), getString(R.string.msg_no_playable_items));
closePlayer();
return;
}
if (mItemsToPlay == null || mItemsToPlay.isEmpty()) return;
int mediaPosition = videoQueueManager.getValue().getCurrentMediaPosition();
@@ -186,7 +164,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
// setup fade task
mHideTask = () -> {
if (mIsVisible) {
hide();
leanbackOverlayFragment.hideOverlay();
}
};
@@ -221,9 +198,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
tvGuideBinding.getRoot().setVisibility(View.GONE);
binding.getRoot().setOnTouchListener((v, event) -> {
//if we're not visible, show us
if (!mIsVisible) show();
//and then manage our fade timer
if (mFadeEnabled) startFadeTimer();
@@ -238,6 +212,12 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
if (mItemsToPlay == null || mItemsToPlay.isEmpty()) {
Utils.showToast(requireContext(), getString(R.string.msg_no_playable_items));
closePlayer();
return;
}
if (playbackControllerContainer.getValue().getPlaybackController() != null) {
playbackControllerContainer.getValue().getPlaybackController().init(new VideoManager((requireActivity()), view, helper), this);
}
@@ -255,30 +235,10 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
if (mItemsToPlay == null || mItemsToPlay.size() == 0) return;
if (mItemsToPlay == null || mItemsToPlay.isEmpty()) return;
prepareOverlayFragment();
//manual subtitles
// This configuration is required for the PaddedLineBackgroundSpan to work
binding.subtitlesText.setShadowLayer(SUBTITLE_PADDING, 0, 0, Color.TRANSPARENT);
binding.subtitlesText.setPadding(SUBTITLE_PADDING, 0, SUBTITLE_PADDING, 0);
// Subtitles font size configuration
binding.subtitlesText.setTextSize(subtitlesSize);
// Subtitles font position (margin bottom)
if (subtitlesPosition > 0) {
ViewGroup.MarginLayoutParams currentLayoutParams = (ViewGroup.MarginLayoutParams) binding.subtitlesText.getLayoutParams();
currentLayoutParams.bottomMargin = (8 + Utils.convertDpToPixel(requireContext(), subtitlesPosition));
binding.subtitlesText.setLayoutParams(currentLayoutParams);
}
// Subtitles stroke width
if (subtitlesStrokeWidth > 0 && !subtitlesBackgroundEnabled) {
binding.subtitlesText.setStrokeWidth(subtitlesStrokeWidth);
}
//pre-load animations
fadeOut = AnimationUtils.loadAnimation(requireContext(), androidx.leanback.R.anim.abc_fade_out);
fadeOut.setAnimationListener(hideAnimationListener);
@@ -331,14 +291,21 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
return null;
});
requireActivity().getOnBackPressedDispatcher().addCallback(backPressedCallback);
int startPos = getArguments().getInt("Position", 0);
// start playing
playbackControllerContainer.getValue().getPlaybackController().play(startPos);
leanbackOverlayFragment.updatePlayState();
// Set initial skip overlay state
binding.skipOverlay.setSkipUiEnabled(!mIsVisible && !mGuideVisible && !mPopupPanelVisible);
}
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
requireActivity().getOnBackPressedDispatcher().addCallback(this, backPressedCallback);
}
private void prepareOverlayFragment() {
@@ -418,7 +385,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
};
private OnBackPressedCallback backPressedCallback = new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
if (mPopupPanelVisible) {
@@ -510,6 +476,22 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
leanbackOverlayFragment.hideOverlay();
}
if (binding.skipOverlay.getVisible()) {
// Hide without doing anything
if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_BUTTON_B || keyCode == KeyEvent.KEYCODE_ESCAPE) {
binding.skipOverlay.setTargetPositionMs(null);
return true;
}
// Hide with seek
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) {
playbackControllerContainer.getValue().getPlaybackController().seek(binding.skipOverlay.getTargetPositionMs(), true);
leanbackOverlayFragment.setShouldShowOverlay(false);
if (binding != null) binding.skipOverlay.setTargetPositionMs(null);
return true;
}
}
if (keyCode == KeyEvent.KEYCODE_MEDIA_STOP) {
closePlayer();
return true;
@@ -575,7 +557,8 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
// up or down should close panel
if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN || keyCode == KeyEvent.KEYCODE_DPAD_UP) {
hidePopupPanel();
if (playbackControllerContainer.getValue().getPlaybackController().isLiveTv()) hide(); //also close this if live tv
if (playbackControllerContainer.getValue().getPlaybackController().isLiveTv())
hide(); //also close this if live tv
return true;
} else {
return false;
@@ -617,9 +600,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
playbackControllerContainer.getValue().getPlaybackController().playPause();
return true;
}
//if we're not visible, show us
show();
}
//and then manage our fade timer
@@ -673,13 +653,10 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
public void onResume() {
super.onResume();
// Close player when resuming without a valid playback contoller
if (!playbackControllerContainer.getValue().getPlaybackController().hasFragment()) {
if (navigationRepository.getValue().getCanGoBack()) {
navigationRepository.getValue().goBack();
} else {
navigationRepository.getValue().reset(Destinations.INSTANCE.getHome());
}
// Close player when resuming without a valid playback controller
PlaybackController playbackController = playbackControllerContainer.getValue().getPlaybackController();
if (playbackController == null || !playbackController.hasFragment()) {
closePlayer();
return;
}
@@ -698,6 +675,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
@Override
public void onPause() {
super.onPause();
if (mItemsToPlay == null || mItemsToPlay.isEmpty()) return;
setPlayPauseActionState(0);
@@ -712,10 +690,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
if (leanbackOverlayFragment != null)
leanbackOverlayFragment.setOnKeyInterceptListener(null);
if (backPressedCallback != null) {
backPressedCallback.remove();
backPressedCallback = null;
}
// end playback from here if this fragment belongs to the current session.
// if it doesn't, playback has already been stopped elsewhere, and the references to this have been replaced
@@ -723,27 +697,39 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
Timber.d("this fragment belongs to the current session, ending it");
playbackControllerContainer.getValue().getPlaybackController().endPlayback();
}
closePlayer();
}
public void show() {
// Already showing!
if (mIsVisible) return;
binding.topPanel.startAnimation(slideDown);
mIsVisible = true;
binding.skipOverlay.setSkipUiEnabled(!mIsVisible && !mGuideVisible && !mPopupPanelVisible);
}
public void hide() {
// Can't hide what's already hidden
if (!mIsVisible) return;
mIsVisible = false;
binding.topPanel.startAnimation(fadeOut);
binding.skipOverlay.setSkipUiEnabled(!mIsVisible && !mGuideVisible && !mPopupPanelVisible);
}
private void showChapterPanel() {
setFadingEnabled(false);
binding.popupArea.startAnimation(showPopup);
binding.skipOverlay.setSkipUiEnabled(!mIsVisible && !mGuideVisible && !mPopupPanelVisible);
}
private void hidePopupPanel() {
startFadeTimer();
binding.popupArea.startAnimation(hidePopup);
mPopupPanelVisible = false;
binding.skipOverlay.setSkipUiEnabled(!mIsVisible && !mGuideVisible && !mPopupPanelVisible);
}
public void showGuide() {
@@ -764,12 +750,14 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
if (needLoad) {
loadGuide();
}
binding.skipOverlay.setSkipUiEnabled(!mIsVisible && !mGuideVisible && !mPopupPanelVisible);
}
private void hideGuide() {
tvGuideBinding.getRoot().setVisibility(View.GONE);
playbackControllerContainer.getValue().getPlaybackController().mVideoManager.setVideoFullSize(true);
mGuideVisible = false;
binding.skipOverlay.setSkipUiEnabled(!mIsVisible && !mGuideVisible && !mPopupPanelVisible);
}
private void loadGuide() {
@@ -819,6 +807,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
mDisplayProgramsTask.execute(mCurrentDisplayChannelStartNdx, mCurrentDisplayChannelEndNdx);
}
});
binding.skipOverlay.setSkipUiEnabled(!mIsVisible && !mGuideVisible && !mPopupPanelVisible);
}
DisplayProgramsTask mDisplayProgramsTask;
@@ -883,8 +872,8 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
// set focus parameters if we are not on first row
// this makes focus movements more predictable for the grid view
if (prevRow != null) {
TvManager.setFocusParms(row, prevRow, true);
TvManager.setFocusParms(prevRow, row, false);
TvManager.setFocusParams(row, prevRow, true);
TvManager.setFocusParams(prevRow, row, false);
}
prevRow = row;
@@ -1216,6 +1205,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
}
public void setCurrentTime(long time) {
binding.skipOverlay.setCurrentPositionMs(time);
if (leanbackOverlayFragment != null)
leanbackOverlayFragment.updateCurrentPosition();
}
@@ -1225,7 +1215,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
public void setFadingEnabled(boolean value) {
mFadeEnabled = value;
if (!mIsVisible) requireActivity().runOnUiThread(this::show);
if (mFadeEnabled) {
startFadeTimer();
} else {
@@ -1302,6 +1291,9 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
}
public void closePlayer() {
if (navigating) return;
navigating = true;
if (navigationRepository.getValue().getCanGoBack()) {
navigationRepository.getValue().goBack();
} else {
@@ -1310,140 +1302,12 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
}
public void showNextUp(@NonNull UUID id) {
if (navigating) return;
navigating = true;
navigationRepository.getValue().navigate(Destinations.INSTANCE.nextUp(id), true);
}
public void addManualSubtitles(@Nullable SubtitleTrackInfo info) {
subtitleTrackInfo = info;
currentSubtitleIndex = -1;
lastSubtitlePositionMs = 0;
clearSubtitles();
}
public void showSubLoadingMsg(final boolean show) {
if (show) {
renderSubtitles(requireContext().getString(R.string.msg_subtitles_loading));
} else {
clearSubtitles();
}
}
public void updateSubtitles(long positionMs) {
int iterCount = 1;
final long positionTicks = positionMs * 10000;
final long lastPositionTicks = lastSubtitlePositionMs * 10000;
if (subtitleTrackInfo == null
|| subtitleTrackInfo.getTrackEvents() == null
|| subtitleTrackInfo.getTrackEvents().size() < 1
|| currentSubtitleIndex >= subtitleTrackInfo.getTrackEvents().size()) {
return;
}
if (positionTicks < subtitleTrackInfo.getTrackEvents().get(0).getStartPositionTicks())
return;
// Skip rendering if the interval ms have not passed since last render
if (lastSubtitlePositionMs > 0
&& Math.abs(lastSubtitlePositionMs - positionMs) < SUBTITLE_RENDER_INTERVAL_MS) {
return;
}
// If the user has skipped back, reset the subtitle index
if (lastSubtitlePositionMs > positionMs) {
currentSubtitleIndex = -1;
clearSubtitles();
}
if (currentSubtitleIndex == -1)
Timber.d("subtitle track events size %s", subtitleTrackInfo.getTrackEvents().size());
// Find the next subtitle event that should be rendered
for (int tmpSubtitleIndex = currentSubtitleIndex == -1 ? 0 : currentSubtitleIndex; tmpSubtitleIndex < subtitleTrackInfo.getTrackEvents().size(); tmpSubtitleIndex++) {
SubtitleTrackEvent trackEvent = subtitleTrackInfo.getTrackEvents().get(tmpSubtitleIndex);
if (positionTicks >= trackEvent.getStartPositionTicks()
&& positionTicks < trackEvent.getEndPositionTicks()) {
// This subtitle event should be displayed now
// use lastPositionTicks to ensure it is only rendered once
if (lastPositionTicks < trackEvent.getStartPositionTicks() || lastPositionTicks >= trackEvent.getEndPositionTicks()) {
Timber.d("rendering subtitle event: %s (pos %s start %s end %s)", tmpSubtitleIndex, positionMs, trackEvent.getStartPositionTicks() / 10000, trackEvent.getEndPositionTicks() / 10000);
renderSubtitles(trackEvent.getText());
}
currentSubtitleIndex = tmpSubtitleIndex;
lastSubtitlePositionMs = positionMs;
// rendering should happen on the 2nd iteration
if (iterCount > 2)
Timber.d("subtitles handled in %s iterations", iterCount);
return;
} else if (tmpSubtitleIndex < subtitleTrackInfo.getTrackEvents().size() - 1) {
SubtitleTrackEvent nextTrackEvent = subtitleTrackInfo.getTrackEvents().get(tmpSubtitleIndex + 1);
if (positionTicks >= trackEvent.getEndPositionTicks() && positionTicks < nextTrackEvent.getStartPositionTicks()) {
// clear the subtitles if between events
// use lastPositionTicks to ensure it is only cleared once
if (currentSubtitleIndex > -1 && !(lastPositionTicks >= trackEvent.getEndPositionTicks() && lastPositionTicks < nextTrackEvent.getStartPositionTicks())) {
Timber.d("clearing subtitle event: %s (pos %s - event end %s)", tmpSubtitleIndex, positionMs, trackEvent.getEndPositionTicks() / 10000);
clearSubtitles();
}
// set currentSubtitleIndex in case it was -1
currentSubtitleIndex = tmpSubtitleIndex;
lastSubtitlePositionMs = positionMs;
if (iterCount > 1)
Timber.d("subtitles handled in %s iterations", iterCount);
return;
}
}
iterCount++;
}
// handles clearing the last event
if (iterCount > 1)
Timber.d("subtitles handled in %s iterations", iterCount);
clearSubtitles();
}
private void clearSubtitles() {
requireActivity().runOnUiThread(() -> {
binding.subtitlesText.setVisibility(View.INVISIBLE);
binding.subtitlesText.setText(null);
});
}
private void renderSubtitles(@Nullable final String text) {
if (text == null || text.length() == 0) {
clearSubtitles();
return;
}
requireActivity().runOnUiThread(() -> {
final String htmlText = text
// Encode whitespace as html entities
.replaceAll("\\r\\n", "<br>")
.replaceAll("\\n", "<br>")
.replaceAll("\\\\h", "&ensp;")
// Remove SSA tags
.replaceAll("\\{\\\\.*?\\}", "");
final SpannableString span = new SpannableString(TextUtilsKt.toHtmlSpanned(htmlText));
if (subtitlesBackgroundEnabled) {
// Disable the text outlining when the background is enabled
binding.subtitlesText.setStrokeWidth(0.0f);
// get the alignment gravity of the TextView
// extract the absolute horizontal gravity so the span can draw its background aligned
int gravity = binding.subtitlesText.getGravity();
int horizontalGravity = Gravity.getAbsoluteGravity(gravity, binding.subtitlesText.getLayoutDirection()) & Gravity.HORIZONTAL_GRAVITY_MASK;
span.setSpan(new PaddedLineBackgroundSpan(ContextCompat.getColor(requireContext(), R.color.black_opaque), SUBTITLE_PADDING, horizontalGravity), 0, span.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
binding.subtitlesText.setText(span);
binding.subtitlesText.setVisibility(View.VISIBLE);
});
}
@Override
public void onDestroy() {
super.onDestroy();
@@ -1451,5 +1315,12 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
// Show system bars
WindowCompat.setDecorFitsSystemWindows(requireActivity().getWindow(), true);
WindowCompat.getInsetsController(requireActivity().getWindow(), requireActivity().getWindow().getDecorView()).show(WindowInsetsCompat.Type.systemBars());
// Reset display mode
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
WindowManager.LayoutParams params = getActivity().getWindow().getAttributes();
params.preferredDisplayModeId = 0;
getActivity().getWindow().setAttributes(params);
}
}
}

View File

@@ -19,6 +19,7 @@ import org.koin.android.ext.android.inject
import timber.log.Timber
import java.time.Instant
import java.util.UUID
import kotlin.time.Duration
fun CustomPlaybackOverlayFragment.toggleFavorite() {
val header = mSelectedProgramView as? GuideChannelHeader
@@ -174,3 +175,7 @@ fun CustomPlaybackOverlayFragment.recordProgram(program: BaseItemDto, isSeries:
)
}
}
fun CustomPlaybackOverlayFragment.askToSkip(position: Duration) {
binding.skipOverlay.targetPosition = position
}

View File

@@ -125,7 +125,7 @@ public class ExternalPlayerActivity extends FragmentActivity {
if (data.hasExtra(API_MX_RESULT_POSITION)) {
pos = data.getIntExtra(API_MX_RESULT_POSITION, 0);
} else if (data.hasExtra(API_VLC_RESULT_POSITION)) {
pos = data.getIntExtra(API_VLC_RESULT_POSITION, 0);
pos = (int) data.getLongExtra(API_VLC_RESULT_POSITION, 0);
} else if (data.hasExtra(API_VIMU_RESULT_POSITION)) {
pos = data.getIntExtra(API_VIMU_RESULT_POSITION, 0);
}
@@ -152,7 +152,7 @@ public class ExternalPlayerActivity extends FragmentActivity {
Long reportPos = (long) pos * RUNTIME_TICKS_TO_MS;
stopReportLoop();
reportingHelper.getValue().reportStopped(item, mCurrentStreamInfo, reportPos);
reportingHelper.getValue().reportStopped(this, item, mCurrentStreamInfo, reportPos);
//Check against a total failure (no apps installed)
if (playerFinishedTime - mLastPlayerStart < 1000) {
@@ -232,13 +232,13 @@ public class ExternalPlayerActivity extends FragmentActivity {
private void startReportLoop() {
PlaybackController playbackController = playbackControllerContainer.getValue().getPlaybackController();
reportingHelper.getValue().reportProgress(playbackController, mItemsToPlay.get(mCurrentNdx), mCurrentStreamInfo, null, false);
reportingHelper.getValue().reportProgress(this, playbackController, mItemsToPlay.get(mCurrentNdx), mCurrentStreamInfo, 0, false);
mReportLoop = new Runnable() {
@Override
public void run() {
if (!getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) return;
reportingHelper.getValue().reportProgress(playbackController, mItemsToPlay.get(mCurrentNdx), mCurrentStreamInfo, mPosition * RUNTIME_TICKS_TO_MS, false);
reportingHelper.getValue().reportProgress(ExternalPlayerActivity.this, playbackController, mItemsToPlay.get(mCurrentNdx), mCurrentStreamInfo, mPosition * RUNTIME_TICKS_TO_MS, false);
mHandler.postDelayed(this, 15000);
}
};
@@ -374,7 +374,7 @@ public class ExternalPlayerActivity extends FragmentActivity {
try {
mLastPlayerStart = Instant.now().toEpochMilli();
reportingHelper.getValue().reportStart(item, mPosition * RUNTIME_TICKS_TO_MS);
reportingHelper.getValue().reportStart(this, playbackControllerContainer.getValue().getPlaybackController(), item, mCurrentStreamInfo, mPosition * RUNTIME_TICKS_TO_MS, false);
startReportLoop();
startActivityForResult(external, 1);
} catch (ActivityNotFoundException e) {

View File

@@ -8,7 +8,6 @@ import org.jellyfin.androidtv.util.sdk.compat.ModelCompat;
import org.jellyfin.apiclient.interaction.ApiClient;
import org.jellyfin.apiclient.interaction.QueryStringDictionary;
import org.jellyfin.apiclient.interaction.Response;
import org.jellyfin.apiclient.model.dlna.PlaybackErrorCode;
import org.jellyfin.apiclient.model.dto.MediaSourceInfo;
import org.jellyfin.apiclient.model.mediainfo.LiveStreamRequest;
import org.jellyfin.apiclient.model.mediainfo.LiveStreamResponse;
@@ -26,9 +25,8 @@ public class GetPlaybackInfoResponse extends Response<PlaybackInfoResponse> {
private AudioOptions options;
private Response<StreamInfo> response;
private boolean isVideo;
private Long startPositionTicks;
public GetPlaybackInfoResponse(PlaybackManager playbackManager, DeviceInfo deviceInfo, ApiClient apiClient, AudioOptions options, Response<StreamInfo> response, boolean isVideo, Long startPositionTicks) {
public GetPlaybackInfoResponse(PlaybackManager playbackManager, DeviceInfo deviceInfo, ApiClient apiClient, AudioOptions options, Response<StreamInfo> response, boolean isVideo) {
super(response);
this.playbackManager = playbackManager;
this.deviceInfo = deviceInfo;
@@ -36,7 +34,6 @@ public class GetPlaybackInfoResponse extends Response<PlaybackInfoResponse> {
this.options = options;
this.response = response;
this.isVideo = isVideo;
this.startPositionTicks = startPositionTicks;
}
@Override
@@ -96,7 +93,6 @@ public class GetPlaybackInfoResponse extends Response<PlaybackInfoResponse> {
streamInfo.setItemId(options.getItemId());
streamInfo.setDeviceProfile(options.getProfile());
streamInfo.setPlaySessionId(playbackInfo.getPlaySessionId());
streamInfo.setStartPositionTicks(startPositionTicks);
if (options.getEnableDirectPlay() && mediaSourceInfo.getSupportsDirectPlay()){
if (canDirectPlay(mediaSourceInfo)) {
@@ -169,14 +165,6 @@ public class GetPlaybackInfoResponse extends Response<PlaybackInfoResponse> {
streamInfo.setMediaUrl(apiClient.GetApiUrl(mediaSourceInfo.getTranscodingUrl()));
}
// A null url will crash the app, make sure to call onError instead
if (streamInfo.getMediaUrl() == null) {
PlaybackException exception = new PlaybackException();
exception.setErrorCode(PlaybackErrorCode.NoCompatibleStream);
response.onError(exception);
return;
}
playbackManager.SendResponse(response, streamInfo);
}

View File

@@ -15,26 +15,23 @@ import androidx.annotation.Nullable;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.data.compat.PlaybackException;
import org.jellyfin.androidtv.data.compat.StreamInfo;
import org.jellyfin.androidtv.data.compat.SubtitleStreamInfo;
import org.jellyfin.androidtv.data.compat.VideoOptions;
import org.jellyfin.androidtv.data.model.DataRefreshService;
import org.jellyfin.androidtv.preference.UserPreferences;
import org.jellyfin.androidtv.preference.UserSettingPreferences;
import org.jellyfin.androidtv.preference.constant.AudioBehavior;
import org.jellyfin.androidtv.preference.constant.NextUpBehavior;
import org.jellyfin.androidtv.preference.constant.RefreshRateSwitchingBehavior;
import org.jellyfin.androidtv.preference.constant.ZoomMode;
import org.jellyfin.androidtv.ui.livetv.TvManager;
import org.jellyfin.androidtv.util.TimeUtils;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.androidtv.util.apiclient.ReportingHelper;
import org.jellyfin.androidtv.util.apiclient.StreamHelper;
import org.jellyfin.androidtv.util.profile.ExoPlayerProfile;
import org.jellyfin.androidtv.util.sdk.ModelUtils;
import org.jellyfin.androidtv.util.sdk.compat.JavaCompat;
import org.jellyfin.apiclient.interaction.ApiClient;
import org.jellyfin.apiclient.interaction.Response;
import org.jellyfin.apiclient.model.dlna.DeviceProfile;
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod;
import org.jellyfin.apiclient.model.mediainfo.SubtitleTrackInfo;
import org.jellyfin.apiclient.model.session.PlayMethod;
import org.jellyfin.sdk.model.api.BaseItemDto;
import org.jellyfin.sdk.model.api.BaseItemKind;
@@ -42,6 +39,7 @@ import org.jellyfin.sdk.model.api.LocationType;
import org.jellyfin.sdk.model.api.MediaSourceInfo;
import org.jellyfin.sdk.model.api.MediaStream;
import org.jellyfin.sdk.model.api.MediaStreamType;
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod;
import org.jellyfin.sdk.model.serializer.UUIDSerializerKt;
import org.koin.java.KoinJavaComponent;
@@ -70,20 +68,18 @@ public class PlaybackController implements PlaybackControllerNotifiable {
List<BaseItemDto> mItems;
VideoManager mVideoManager;
int mCurrentIndex;
private long mCurrentPosition = 0;
protected long mCurrentPosition = 0;
private PlaybackState mPlaybackState = PlaybackState.IDLE;
private StreamInfo mCurrentStreamInfo;
private List<SubtitleStreamInfo> mSubtitleStreams;
@Nullable
private CustomPlaybackOverlayFragment mFragment;
private Boolean spinnerOff = false;
private VideoOptions mCurrentOptions;
private int mDefaultSubIndex = -1;
protected VideoOptions mCurrentOptions;
private int mDefaultAudioIndex = -1;
private boolean burningSubs = false;
protected boolean burningSubs = false;
private float mRequestedPlaybackSpeed = -1.0f;
private Runnable mReportLoop;
@@ -101,7 +97,6 @@ public class PlaybackController implements PlaybackControllerNotifiable {
private long mCurrentTranscodeStartTime;
private boolean isLiveTv = false;
private boolean directStreamLiveTv;
private boolean exoErrorEncountered;
private int playbackRetries = 0;
private long lastPlaybackError = 0;
@@ -139,6 +134,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
public void init(@NonNull VideoManager mgr, @NonNull CustomPlaybackOverlayFragment fragment) {
mVideoManager = mgr;
mVideoManager.subscribe(this);
mVideoManager.setZoom(userPreferences.getValue().get(UserPreferences.Companion.getPlayerZoomMode()));
mFragment = fragment;
directStreamLiveTv = userPreferences.getValue().get(UserPreferences.Companion.getLiveTvDirectPlayEnabled());
}
@@ -211,18 +207,6 @@ public class PlaybackController implements PlaybackControllerNotifiable {
return (mCurrentOptions != null && mCurrentOptions.getSubtitleStreamIndex() != null) ? mCurrentOptions.getSubtitleStreamIndex() : -1;
}
public List<SubtitleStreamInfo> getSubtitleStreams() {
return mSubtitleStreams;
}
public SubtitleStreamInfo getSubtitleStreamInfo(int index) {
for (SubtitleStreamInfo info : mSubtitleStreams) {
if (info.getIndex() == index) return info;
}
return null;
}
public boolean isTranscoding() {
// use or here so that true is the default since
// this method is used to exclude features that may break unless we are sure playback is direct
@@ -247,8 +231,6 @@ public class PlaybackController implements PlaybackControllerNotifiable {
}
public void playerErrorEncountered() {
exoErrorEncountered = true;
// reset the retry count if it's been more than 30s since previous error
if (playbackRetries > 0 && Instant.now().toEpochMilli() - lastPlaybackError > 30000) {
Timber.d("playback stabilized - retry count reset to 0 from %s", playbackRetries);
@@ -305,7 +287,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
if (mode.getPhysicalWidth() < 1280 || mode.getPhysicalHeight() < 720) // Skip non-HD
continue;
if (mode.getPhysicalWidth() < videoStream.getWidth() || mode.getPhysicalHeight() < videoStream.getHeight()) // Disallow reso downgrade
if (mode.getPhysicalWidth() < videoStream.getWidth() || mode.getPhysicalHeight() < videoStream.getHeight()) // Disallow resolution downgrade
continue;
int rate = Math.round(mode.getRefreshRate() * 100);
@@ -403,7 +385,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
play(position, null);
}
private void play(long position, @Nullable Integer forcedSubtitleIndex) {
protected void play(long position, @Nullable Integer forcedSubtitleIndex) {
Timber.i("Play called from state: %s with pos: %d and sub index: %d", mPlaybackState, position, forcedSubtitleIndex);
if (mFragment == null) {
@@ -516,18 +498,23 @@ public class PlaybackController implements PlaybackControllerNotifiable {
internalOptions.setItemId(item.getId());
internalOptions.setMediaSources(item.getMediaSources());
internalOptions.setMaxBitrate(maxBitrate);
if (exoErrorEncountered || (isLiveTv && !directStreamLiveTv))
internalOptions.setEnableDirectStream(false);
internalOptions.setMaxAudioChannels(Utils.downMixAudio(mFragment.getContext()) ? 2 : null); //have to downmix at server
internalOptions.setSubtitleStreamIndex(forcedSubtitleIndex);
if (playbackRetries > 0 || (isLiveTv && !directStreamLiveTv)) internalOptions.setEnableDirectStream(false);
if (playbackRetries > 1) internalOptions.setEnableDirectPlay(false);
if (mCurrentOptions != null) {
internalOptions.setSubtitleStreamIndex(mCurrentOptions.getSubtitleStreamIndex());
internalOptions.setAudioStreamIndex(mCurrentOptions.getAudioStreamIndex());
}
if (forcedSubtitleIndex != null) {
internalOptions.setSubtitleStreamIndex(forcedSubtitleIndex);
}
MediaSourceInfo currentMediaSource = getCurrentMediaSource();
if (!isLiveTv && currentMediaSource != null) {
internalOptions.setMediaSourceId(currentMediaSource.getId());
}
DeviceProfile internalProfile = new ExoPlayerProfile(
mFragment.getContext(),
isLiveTv && !userPreferences.getValue().get(UserPreferences.Companion.getLiveTvDirectPlayEnabled()),
userPreferences.getValue().get(UserPreferences.Companion.getAc3Enabled())
userPreferences.getValue().get(UserPreferences.Companion.getAc3Enabled()),
userPreferences.getValue().get(UserPreferences.Companion.getAudioBehaviour()) == AudioBehavior.DOWNMIX_TO_STEREO
);
internalOptions.setProfile(internalProfile);
return internalOptions;
@@ -561,6 +548,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
if (mVideoManager == null)
return;
mCurrentOptions = internalOptions;
if (internalOptions.getSubtitleStreamIndex() == null) burningSubs = internalResponse.getSubtitleDeliveryMethod() == SubtitleDeliveryMethod.ENCODE;
startItem(item, position, internalResponse);
}
@@ -603,29 +591,26 @@ public class PlaybackController implements PlaybackControllerNotifiable {
}
mStartPosition = position;
mCurrentStreamInfo = response;
// set media source Id in case we need to switch to transcoding
mCurrentOptions.setMediaSourceId(response.getMediaSource().getId());
if (response.getMediaUrl() == null) {
// If baking subtitles doesn't work (e.g. no permissions to transcode), disable them
if (response.getSubtitleDeliveryMethod() == SubtitleDeliveryMethod.ENCODE && (response.getMediaSource().getDefaultSubtitleStreamIndex() == null || response.getMediaSource().getDefaultSubtitleStreamIndex() != -1)) {
burningSubs = false;
stop();
play(position, -1);
} else {
handlePlaybackInfoError(null);
}
return;
}
// get subtitle info
mSubtitleStreams = response.getSubtitleProfiles(false, apiClient.getValue().getApiUrl(), apiClient.getValue().getAccessToken());
mDefaultSubIndex = response.getMediaSource().getDefaultSubtitleStreamIndex() != null ? response.getMediaSource().getDefaultSubtitleStreamIndex() : mDefaultSubIndex;
mCurrentOptions.setSubtitleStreamIndex(response.getMediaSource().getDefaultSubtitleStreamIndex() != null ? response.getMediaSource().getDefaultSubtitleStreamIndex() : null);
setDefaultAudioIndex(response);
Timber.d("default audio index set to %s remote default %s", mDefaultAudioIndex, response.getMediaSource().getDefaultAudioStreamIndex());
Timber.d("default sub index set to %s remote default %s", mDefaultSubIndex, response.getMediaSource().getDefaultSubtitleStreamIndex());
// if burning in, set the subtitle index and the burningSubs flag so that onPrepared and switchSubtitleStream will know that we already have subtitles enabled
burningSubs = false;
if (mCurrentStreamInfo.getPlayMethod() == PlayMethod.Transcode && getSubtitleStreamInfo(mDefaultSubIndex) != null &&
getSubtitleStreamInfo(mDefaultSubIndex).getDeliveryMethod() == SubtitleDeliveryMethod.Encode) {
mCurrentOptions.setSubtitleStreamIndex(mDefaultSubIndex);
Timber.d("stream started with burnt in subs");
burningSubs = true;
} else {
mCurrentOptions.setSubtitleStreamIndex(null);
}
Timber.d("default sub index set to %s remote default %s", mCurrentOptions.getSubtitleStreamIndex(), response.getMediaSource().getDefaultSubtitleStreamIndex());
Long mbPos = position * 10000;
@@ -641,20 +626,30 @@ public class PlaybackController implements PlaybackControllerNotifiable {
if (mFragment != null) mFragment.updateDisplay();
if (mVideoManager != null) {
mVideoManager.setVideoPath(response.getMediaUrl());
mVideoManager.setMediaStreamInfo(api.getValue(), response);
}
//wait a beat before attempting to start so the player surface is fully initialized and video is ready
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
if (mVideoManager != null)
mVideoManager.start();
PlaybackControllerHelperKt.applyMediaSegments(this, item, () -> {
// Set video start delay
long videoStartDelay = userPreferences.getValue().get(UserPreferences.Companion.getVideoStartDelay());
if (videoStartDelay > 0) {
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
if (mVideoManager != null) {
mVideoManager.start();
}
}
}, videoStartDelay);
} else {
mVideoManager.start();
}
}, 750);
dataRefreshService.getValue().setLastPlayedItem(item);
reportingHelper.getValue().reportStart(item, mbPos);
dataRefreshService.getValue().setLastPlayedItem(item);
reportingHelper.getValue().reportStart(mFragment, PlaybackController.this, item, response, mbPos, false);
return null;
});
}
public void startSpinner() {
@@ -745,117 +740,6 @@ public class PlaybackController implements PlaybackControllerNotifiable {
}
}
public void switchSubtitleStream(int index) {
if (!hasInitializedVideoManager())
return;
// get current timestamp first
refreshCurrentPosition();
Timber.d("Setting subtitle index to: %d", index);
// clear subtitles first
if (mFragment != null) mFragment.addManualSubtitles(null);
mVideoManager.disableSubs();
// clear the default in case there's an error loading the subtitles
mDefaultSubIndex = -1;
// handle setting subtitles as disabled
// restart playback if turning off burnt-in subtitles
if (index < 0) {
mCurrentOptions.setSubtitleStreamIndex(-1);
if (burningSubs) {
stop();
play(mCurrentPosition, -1);
}
return;
}
org.jellyfin.sdk.model.api.MediaStream stream = StreamHelper.getMediaStream(getCurrentMediaSource(), index);
if (stream == null) {
if (mFragment != null)
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.subtitle_error));
return;
}
SubtitleStreamInfo streamInfo = getSubtitleStreamInfo(index);
if (streamInfo == null) {
if (mFragment != null)
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.msg_unable_load_subs));
return;
}
// handle switching on or between burnt-in subtitles, or switching to non-burnt subtitles
// if switching from burnt-in subtitles to another type, playback still needs to be restarted
if (burningSubs || streamInfo.getDeliveryMethod() == SubtitleDeliveryMethod.Encode) {
stop();
if (mFragment != null && streamInfo.getDeliveryMethod() == SubtitleDeliveryMethod.Encode)
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.msg_burn_sub_warning));
play(mCurrentPosition, index);
return;
}
// when burnt-in subtitles are selected, mCurrentOptions SubtitleStreamIndex is set in startItem() as soon as playback starts
// otherwise mCurrentOptions SubtitleStreamIndex is kept null until now so we knew subtitles needed to be enabled but weren't already
switch (streamInfo.getDeliveryMethod()) {
case Embed:
if (!mVideoManager.setExoPlayerTrack(index, MediaStreamType.SUBTITLE, getCurrentlyPlayingItem().getMediaStreams())) {
// error selecting internal subs
if (mFragment != null)
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.msg_unable_load_subs));
} else {
mCurrentOptions.setSubtitleStreamIndex(index);
mDefaultSubIndex = index;
}
break;
case External:
if (mFragment != null) mFragment.showSubLoadingMsg(true);
stream = ModelUtils.withDelivery(
stream,
org.jellyfin.sdk.model.api.SubtitleDeliveryMethod.EXTERNAL,
String.format(
"%1$s/Videos/%2$s/%3$s/Subtitles/%4$s/0/Stream.JSON",
apiClient.getValue().getApiUrl(),
mCurrentStreamInfo.getItemId(),
mCurrentStreamInfo.getMediaSourceId(),
String.valueOf(stream.getIndex())
)
);
apiClient.getValue().getSubtitles(stream.getDeliveryUrl(), new Response<SubtitleTrackInfo>() {
@Override
public void onResponse(final SubtitleTrackInfo info) {
if (info != null) {
Timber.d("Adding json subtitle track to player");
if (mFragment != null) mFragment.addManualSubtitles(info);
mCurrentOptions.setSubtitleStreamIndex(index);
mDefaultSubIndex = index;
} else {
Timber.e("Empty subtitle result");
if (mFragment != null) {
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.msg_unable_load_subs));
mFragment.showSubLoadingMsg(false);
}
}
}
@Override
public void onError(Exception ex) {
Timber.e(ex, "Error downloading subtitles");
if (mFragment != null) {
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.msg_unable_load_subs));
mFragment.showSubLoadingMsg(false);
}
}
});
break;
case Hls:
break;
}
}
public void pause() {
Timber.d("pause called at %s", mCurrentPosition);
// if playback is paused and the seekbar is scrubbed, it will call pause even if already paused
@@ -897,8 +781,10 @@ public class PlaybackController implements PlaybackControllerNotifiable {
mPlaybackState = PlaybackState.IDLE;
if (mVideoManager != null && mVideoManager.isPlaying()) mVideoManager.stopPlayback();
Long mbPos = mCurrentPosition * 10000;
reportingHelper.getValue().reportStopped(getCurrentlyPlayingItem(), getCurrentStreamInfo(), mbPos);
if (getCurrentlyPlayingItem() != null && mCurrentStreamInfo != null) {
Long mbPos = mCurrentPosition * 10000;
reportingHelper.getValue().reportStopped(mFragment, getCurrentlyPlayingItem(), mCurrentStreamInfo, mbPos);
}
clearPlaybackSessionOptions();
}
}
@@ -926,11 +812,10 @@ public class PlaybackController implements PlaybackControllerNotifiable {
}
private void resetPlayerErrors() {
exoErrorEncountered = false;
playbackRetries = 0;
}
private void clearPlaybackSessionOptions() {
mDefaultSubIndex = -1;
mDefaultAudioIndex = -1;
mSeekPosition = -1;
finishedInitialSeek = false;
@@ -976,7 +861,11 @@ public class PlaybackController implements PlaybackControllerNotifiable {
}
public void seek(long pos) {
pos = Utils.getSafeSeekPosition(pos, getDuration());
seek(pos, false);
}
public void seek(long pos, boolean skipToNext) {
if (pos <= 0) pos = 0;
Timber.d("Trying to seek from %s to %d", mCurrentPosition, pos);
Timber.d("Container: %s", mCurrentStreamInfo == null ? "unknown" : mCurrentStreamInfo.getContainer());
@@ -995,6 +884,20 @@ public class PlaybackController implements PlaybackControllerNotifiable {
}
wasSeeking = true;
// Stop playback when the requested seek position is at the end of the video
if (skipToNext && pos >= (getDuration() - 100)) {
// Since we've skipped ahead, set the current position so the PlaybackStopInfo will report the correct end time
mCurrentPosition = getDuration();
// Make sure we also set the seek positions so mCurrentPosition won't get overwritten in refreshCurrentPosition()
currentSkipPos = mCurrentPosition;
mSeekPosition = mCurrentPosition;
// Finalize item playback
itemComplete();
return;
}
if (pos >= getDuration()) pos = getDuration();
// set seekPosition so real position isn't used until playback starts again
mSeekPosition = pos;
@@ -1011,7 +914,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
public void onResponse(StreamInfo response) {
mCurrentStreamInfo = response;
if (mVideoManager != null) {
mVideoManager.setVideoPath(response.getMediaUrl());
mVideoManager.setMediaStreamInfo(api.getValue(), response);
mVideoManager.start();
}
}
@@ -1097,8 +1000,10 @@ public class PlaybackController implements PlaybackControllerNotifiable {
}
private void startReportLoop() {
if (mCurrentStreamInfo == null) return;
stopReportLoop();
reportingHelper.getValue().reportProgress(this, getCurrentlyPlayingItem(), getCurrentStreamInfo(), mCurrentPosition * 10000, false);
reportingHelper.getValue().reportProgress(mFragment, this, getCurrentlyPlayingItem(), getCurrentStreamInfo(), mCurrentPosition * 10000, false);
mReportLoop = new Runnable() {
@Override
public void run() {
@@ -1106,7 +1011,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
refreshCurrentPosition();
long currentTime = isLiveTv ? getTimeShiftedProgress() : mCurrentPosition;
reportingHelper.getValue().reportProgress(PlaybackController.this, getCurrentlyPlayingItem(), getCurrentStreamInfo(), currentTime * 10000, false);
reportingHelper.getValue().reportProgress(mFragment, PlaybackController.this, getCurrentlyPlayingItem(), getCurrentStreamInfo(), currentTime * 10000, false);
}
if (mPlaybackState != PlaybackState.UNDEFINED && mPlaybackState != PlaybackState.IDLE) {
mHandler.postDelayed(this, PROGRESS_REPORTING_INTERVAL);
@@ -1118,7 +1023,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
private void startPauseReportLoop() {
stopReportLoop();
reportingHelper.getValue().reportProgress(this, getCurrentlyPlayingItem(), getCurrentStreamInfo(), mCurrentPosition * 10000, true);
reportingHelper.getValue().reportProgress(mFragment, this, getCurrentlyPlayingItem(), getCurrentStreamInfo(), mCurrentPosition * 10000, true);
mReportLoop = new Runnable() {
@Override
public void run() {
@@ -1139,7 +1044,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
mFragment.setSecondaryTime(getRealTimeProgress());
}
reportingHelper.getValue().reportProgress(PlaybackController.this, currentItem, getCurrentStreamInfo(), currentTime * 10000, true);
reportingHelper.getValue().reportProgress(mFragment, PlaybackController.this, currentItem, getCurrentStreamInfo(), currentTime * 10000, true);
mHandler.postDelayed(this, PROGRESS_REPORTING_PAUSE_INTERVAL);
}
};
@@ -1205,7 +1110,10 @@ public class PlaybackController implements PlaybackControllerNotifiable {
@Override
public void onPrepared() {
if (mPlaybackState == PlaybackState.BUFFERING) {
if (mFragment != null) mFragment.setFadingEnabled(true);
if (mFragment != null) {
mFragment.setFadingEnabled(true);
mFragment.leanbackOverlayFragment.setShouldShowOverlay(false);
}
mPlaybackState = PlaybackState.PLAYING;
mCurrentTranscodeStartTime = mCurrentStreamInfo.getPlayMethod() == PlayMethod.Transcode ? Instant.now().toEpochMilli() : 0;
@@ -1217,16 +1125,11 @@ public class PlaybackController implements PlaybackControllerNotifiable {
if (mPlaybackState == PlaybackState.PAUSED) {
mPlaybackState = PlaybackState.PLAYING;
} else {
// select or disable subtitles
Integer currentSubtitleIndex = mCurrentOptions.getSubtitleStreamIndex();
if (mDefaultSubIndex >= 0 && currentSubtitleIndex != null && currentSubtitleIndex == mDefaultSubIndex) {
Timber.i("subtitle stream %s is already selected", mDefaultSubIndex);
} else {
if (mDefaultSubIndex < 0)
Timber.i("Turning off subs");
else
Timber.i("Enabling default sub stream: %d", mDefaultSubIndex);
switchSubtitleStream(mDefaultSubIndex);
if (!burningSubs) {
// Make sure the requested subtitles are enabled when external/embedded
Integer currentSubtitleIndex = mCurrentOptions.getSubtitleStreamIndex();
if (currentSubtitleIndex == null) currentSubtitleIndex = -1;
PlaybackControllerHelperKt.setSubtitleIndex(this, currentSubtitleIndex, true);
}
// select an audio track
@@ -1279,9 +1182,6 @@ public class PlaybackController implements PlaybackControllerNotifiable {
stopSpinner();
}
}
if (mFragment != null && finishedInitialSeek)
mFragment.updateSubtitles(mCurrentPosition);
}
if (mFragment != null)
mFragment.setCurrentTime(mCurrentPosition);
@@ -1319,11 +1219,11 @@ public class PlaybackController implements PlaybackControllerNotifiable {
return mPlaybackState == PlaybackState.PAUSED;
}
public int getZoomMode() {
return hasInitializedVideoManager() ? mVideoManager.getZoomMode() : 0;
public @NonNull ZoomMode getZoomMode() {
return hasInitializedVideoManager() ? mVideoManager.getZoomMode() : ZoomMode.FIT;
}
public void setZoom(int mode) {
public void setZoom(@NonNull ZoomMode mode) {
if (hasInitializedVideoManager())
mVideoManager.setZoom(mode);
}

View File

@@ -1,11 +1,24 @@
package org.jellyfin.androidtv.ui.playback
import androidx.annotation.OptIn
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.util.UnstableApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentAction
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepository
import org.jellyfin.androidtv.util.sdk.end
import org.jellyfin.androidtv.util.sdk.start
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import org.koin.android.ext.android.inject
import timber.log.Timber
import java.util.UUID
fun PlaybackController.getLiveTvChannel(
@@ -22,3 +35,166 @@ fun PlaybackController.getLiveTvChannel(
}
}
}
@OptIn(UnstableApi::class)
@JvmOverloads
fun PlaybackController.setSubtitleIndex(index: Int, force: Boolean = false) {
Timber.i("Switching subtitles from index ${mCurrentOptions.subtitleStreamIndex} to $index")
// Already using this subtitle index
if (mCurrentOptions.subtitleStreamIndex == index && !force) return
// Disable subtitles
if (index == -1) {
mCurrentOptions.subtitleStreamIndex = -1
if (burningSubs) {
Timber.i("Disabling subtitle baking")
stop()
burningSubs = false
play(mCurrentPosition, -1)
} else {
Timber.i("Disabling subtitles")
with(mVideoManager.mExoPlayer.trackSelector!!) {
parameters = parameters.buildUpon()
.clearOverridesOfType(C.TRACK_TYPE_TEXT)
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
.build()
}
}
} else if (burningSubs) {
Timber.i("Restarting playback to disable subtitle baking")
// If we're currently burning subs and want to switch streams we need some special behavior
// to stop the current baked subs. We can just stop & start with the new subtitle index for that
stop()
burningSubs = true
mCurrentOptions.subtitleStreamIndex = index
play(mCurrentPosition, index)
} else {
val mediaSource = currentMediaSource
val stream = mediaSource.mediaStreams?.first { it.type == MediaStreamType.SUBTITLE && it.index == index }
if (stream == null) {
Timber.w("Failed to find correct media stream")
return setSubtitleIndex(-1)
}
when (stream.deliveryMethod) {
SubtitleDeliveryMethod.ENCODE -> {
Timber.i("Restarting playback for subtitle baking")
stop()
burningSubs = true
mCurrentOptions.subtitleStreamIndex = index
play(mCurrentPosition, index)
}
SubtitleDeliveryMethod.EXTERNAL,
SubtitleDeliveryMethod.EMBED,
SubtitleDeliveryMethod.HLS -> {
// External subtitles need to be resolved differently
val group = if (stream.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL) {
mVideoManager.mExoPlayer.currentTracks.groups.firstOrNull { group ->
// Verify this is a group with a single format (the subtitles) that is added by us. Because ExoPlayer uses a
// MergingMediaSource, each external subtitle format id is prefixed with its source index (normally starting at 1,
// increasing for each external subttitle). So we only check the end of the id
group.length == 1 && group.getTrackFormat(0).id?.endsWith(":JF_EXTERNAL:$index") == true
}
} else {
// The server does not send a reliable index in all cases, so calculate it manually
val localIndex = mediaSource.mediaStreams.orEmpty()
.filter { it.type == MediaStreamType.SUBTITLE }
.filter { it.deliveryMethod == SubtitleDeliveryMethod.EMBED || it.deliveryMethod == SubtitleDeliveryMethod.HLS }
.indexOf(stream)
.takeIf { index -> index != -1 }
if (localIndex == null) {
Timber.w("Failed to find local subtitle index")
return setSubtitleIndex(-1)
}
mVideoManager.mExoPlayer.currentTracks.groups
.filter { it.type == C.TRACK_TYPE_TEXT }
.filterNot { it.length == 1 && it.getTrackFormat(0).id?.endsWith(":JF_EXTERNAL:$index") == true }
.getOrNull(localIndex)
}?.mediaTrackGroup
if (group == null) {
Timber.w("Failed to find correct subtitle group for method ${stream.deliveryMethod}")
return setSubtitleIndex(-1)
}
Timber.i("Enabling subtitle group $index via method ${stream.deliveryMethod}")
mCurrentOptions.subtitleStreamIndex = index
with(mVideoManager.mExoPlayer.trackSelector!!) {
parameters = parameters.buildUpon()
.clearOverridesOfType(C.TRACK_TYPE_TEXT)
.addOverride(TrackSelectionOverride(group, 0))
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.build()
}
}
SubtitleDeliveryMethod.DROP, null -> {
Timber.i("Dropping subtitles")
setSubtitleIndex(-1)
}
}
}
}
fun PlaybackController.applyMediaSegments(
item: BaseItemDto,
callback: () -> Unit,
) {
val mediaSegmentRepository by fragment.inject<MediaSegmentRepository>()
fragment.lifecycleScope.launch {
val mediaSegments = runCatching {
mediaSegmentRepository.getSegmentsForItem(item)
}.getOrNull().orEmpty()
for (mediaSegment in mediaSegments) {
val action = mediaSegmentRepository.getMediaSegmentAction(mediaSegment)
when (action) {
MediaSegmentAction.SKIP -> addSkipAction(mediaSegment)
MediaSegmentAction.ASK_TO_SKIP -> addAskToSkipAction(mediaSegment)
MediaSegmentAction.NOTHING -> Unit
}
}
callback()
}
}
@OptIn(UnstableApi::class)
private fun PlaybackController.addSkipAction(mediaSegment: MediaSegmentDto) {
mVideoManager.mExoPlayer
.createMessage { _, _ ->
// We can't seek directly on the ExoPlayer instance as not all media is seekable
// the seek function in the PlaybackController checks this and optionally starts a transcode
// at the requested position
fragment.lifecycleScope.launch(Dispatchers.Main) {
seek(mediaSegment.end.inWholeMilliseconds, true)
}
}
// Segments at position 0 will never be hit by ExoPlayer so we need to add a minimum value
.setPosition(mediaSegment.start.inWholeMilliseconds.coerceAtLeast(1))
.setDeleteAfterDelivery(false)
.send()
}
@OptIn(UnstableApi::class)
private fun PlaybackController.addAskToSkipAction(mediaSegment: MediaSegmentDto) {
mVideoManager.mExoPlayer
.createMessage { _, _ ->
fragment?.askToSkip(mediaSegment.end)
}
// Segments at position 0 will never be hit by ExoPlayer so we need to add a minimum value
.setPosition(mediaSegment.start.inWholeMilliseconds.coerceAtLeast(1))
.setDeleteAfterDelivery(false)
.send()
}

View File

@@ -4,15 +4,10 @@ import org.jellyfin.androidtv.data.compat.PlaybackException;
import org.jellyfin.androidtv.data.compat.StreamInfo;
import org.jellyfin.androidtv.data.compat.VideoOptions;
import org.jellyfin.apiclient.interaction.ApiClient;
import org.jellyfin.apiclient.interaction.EmptyResponse;
import org.jellyfin.apiclient.interaction.Response;
import org.jellyfin.apiclient.model.dlna.PlaybackErrorCode;
import org.jellyfin.apiclient.model.mediainfo.PlaybackInfoRequest;
import org.jellyfin.apiclient.model.session.PlaybackProgressInfo;
import org.jellyfin.apiclient.model.session.PlaybackStartInfo;
import org.jellyfin.apiclient.model.session.PlaybackStopInfo;
import org.jellyfin.sdk.model.DeviceInfo;
import org.jellyfin.sdk.model.api.MediaSourceInfo;
import org.jellyfin.sdk.model.api.MediaStream;
import java.util.ArrayList;
@@ -58,15 +53,12 @@ public class PlaybackManager {
request.setAudioStreamIndex(audioIdx);
}
Integer subIdx = options.getSubtitleStreamIndex();
if (subIdx != null) {
request.setSubtitleStreamIndex(subIdx);
}
if (subIdx != null) request.setSubtitleStreamIndex(subIdx);
request.setAllowVideoStreamCopy(true);
request.setAllowAudioStreamCopy(true);
apiClient.GetPlaybackInfoWithPost(request, new GetPlaybackInfoResponse(this, deviceInfo, apiClient, options, response, true, startPositionTicks));
apiClient.GetPlaybackInfoWithPost(request, new GetPlaybackInfoResponse(this, deviceInfo, apiClient, options, response, true));
}
public void changeVideoStream(final StreamInfo currentStreamInfo, DeviceInfo deviceInfo, final VideoOptions options, Long startPositionTicks, ApiClient apiClient, final Response<StreamInfo> response) {
@@ -74,32 +66,4 @@ public class PlaybackManager {
apiClient.StopTranscodingProcesses(api.getDeviceInfo().getId(), playSessionId, new StopTranscodingResponse(this, deviceInfo, options, startPositionTicks, apiClient, response));
}
public void reportPlaybackStart(PlaybackStartInfo info, ApiClient apiClient, EmptyResponse response) {
apiClient.ReportPlaybackStartAsync(info, response);
}
public void reportPlaybackProgress(PlaybackProgressInfo info, final StreamInfo streamInfo, ApiClient apiClient, EmptyResponse response) {
MediaSourceInfo mediaSource = streamInfo.getMediaSource();
if (mediaSource != null) {
info.setLiveStreamId(mediaSource.getLiveStreamId());
}
info.setPlaySessionId(streamInfo.getPlaySessionId());
apiClient.ReportPlaybackProgressAsync(info, response);
}
public void reportPlaybackStopped(PlaybackStopInfo info, final StreamInfo streamInfo, final ApiClient apiClient, final EmptyResponse response) {
MediaSourceInfo mediaSource = streamInfo.getMediaSource();
if (mediaSource != null) {
info.setLiveStreamId(mediaSource.getLiveStreamId());
}
info.setPlaySessionId(streamInfo.getPlaySessionId());
apiClient.ReportPlaybackStoppedAsync(info, response);
}
}

View File

@@ -2,6 +2,7 @@ package org.jellyfin.androidtv.ui.playback;
import android.app.Activity;
import android.content.Context;
import android.graphics.Color;
import android.media.audiofx.DynamicsProcessing;
import android.media.audiofx.DynamicsProcessing.Limiter;
import android.media.audiofx.Equalizer;
@@ -32,19 +33,26 @@ import androidx.media3.exoplayer.DefaultRenderersFactory;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector;
import androidx.media3.exoplayer.util.EventLogger;
import androidx.media3.extractor.DefaultExtractorsFactory;
import androidx.media3.extractor.ts.TsExtractor;
import androidx.media3.ui.AspectRatioFrameLayout;
import androidx.media3.ui.CaptionStyleCompat;
import androidx.media3.ui.PlayerView;
import com.google.common.collect.ImmutableSet;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.data.compat.StreamInfo;
import org.jellyfin.androidtv.preference.UserPreferences;
import org.jellyfin.androidtv.preference.constant.ZoomMode;
import org.jellyfin.playback.media3.exoplayer.mapping.SubtitleKt;
import org.jellyfin.sdk.api.client.ApiClient;
import org.jellyfin.sdk.model.api.MediaStream;
import org.jellyfin.sdk.model.api.MediaStreamType;
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod;
import org.koin.java.KoinJavaComponent;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
@@ -52,19 +60,14 @@ import timber.log.Timber;
@OptIn(markerClass = UnstableApi.class)
public class VideoManager {
public final static int ZOOM_FIT = 0;
public final static int ZOOM_AUTO_CROP = 1;
public final static int ZOOM_STRETCH = 2;
private int mZoomMode = ZOOM_FIT;
private ZoomMode mZoomMode;
private Activity mActivity;
private Equalizer mEqualizer;
private DynamicsProcessing mDynamicsProcessing;
private Limiter mLimiter;
private PlaybackControllerNotifiable mPlaybackControllerNotifiable;
private PlaybackOverlayFragmentHelper _helper;
private ExoPlayer mExoPlayer;
public ExoPlayer mExoPlayer;
private PlayerView mExoPlayerView;
private Handler mHandler = new Handler();
@@ -74,23 +77,35 @@ public class VideoManager {
public boolean isContracted = false;
private final UserPreferences userPreferences = KoinJavaComponent.get(UserPreferences.class);
public VideoManager(@NonNull Activity activity, @NonNull View view, @NonNull PlaybackOverlayFragmentHelper helper) {
mActivity = activity;
_helper = helper;
nightModeEnabled = KoinJavaComponent.<UserPreferences>get(UserPreferences.class).get(UserPreferences.Companion.getAudioNightMode());
nightModeEnabled = userPreferences.get(UserPreferences.Companion.getAudioNightMode());
mExoPlayer = configureExoplayerBuilder(activity).build();
if (userPreferences.get(UserPreferences.Companion.getDebuggingEnabled())) {
mExoPlayer.addAnalyticsListener(new EventLogger());
}
// Volume normalisation (audio night mode).
if (nightModeEnabled) enableAudioNightMode(mExoPlayer.getAudioSessionId());
mExoPlayer.setTrackSelectionParameters(mExoPlayer.getTrackSelectionParameters()
.buildUpon()
.setDisabledTrackTypes(ImmutableSet.of(C.TRACK_TYPE_TEXT))
.build());
mExoPlayerView = view.findViewById(R.id.exoPlayerView);
mExoPlayerView.setPlayer(mExoPlayer);
int strokeColor = userPreferences.get(UserPreferences.Companion.getSubtitleTextStrokeColor()).intValue();
CaptionStyleCompat subtitleStyle = new CaptionStyleCompat(
userPreferences.get(UserPreferences.Companion.getSubtitlesTextColor()).intValue(),
userPreferences.get(UserPreferences.Companion.getSubtitlesBackgroundColor()).intValue(),
Color.TRANSPARENT,
Color.alpha(strokeColor) == 0 ? CaptionStyleCompat.EDGE_TYPE_NONE : CaptionStyleCompat.EDGE_TYPE_OUTLINE,
strokeColor,
null
);
mExoPlayerView.getSubtitleView().setFractionalTextSize(0.0533f * userPreferences.get(UserPreferences.Companion.getSubtitlesTextSize()));
mExoPlayerView.getSubtitleView().setStyle(subtitleStyle);
mExoPlayer.addListener(new Player.Listener() {
@Override
public void onPlayerError(@NonNull PlaybackException error) {
@@ -125,7 +140,7 @@ public class VideoManager {
@Override
public void onPlaybackParametersChanged(@NonNull PlaybackParameters playbackParameters) {
if (mPlaybackControllerNotifiable != null){
if (mPlaybackControllerNotifiable != null) {
mPlaybackControllerNotifiable.onPlaybackSpeedChange(playbackParameters.speed);
}
}
@@ -150,10 +165,18 @@ public class VideoManager {
});
}
public void subscribe(@NonNull PlaybackControllerNotifiable notifier){
public void subscribe(@NonNull PlaybackControllerNotifiable notifier) {
mPlaybackControllerNotifiable = notifier;
}
private int determineExoPlayerExtensionRendererMode() {
if (userPreferences.get(UserPreferences.Companion.getPreferExoPlayerFfmpeg())) {
return DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER;
}
return DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON;
}
/**
* Configures Exoplayer for video playback. Initially we try with core decoders, but allow
* ExoPlayer to silently fallback to software renderers.
@@ -165,7 +188,7 @@ public class VideoManager {
ExoPlayer.Builder exoPlayerBuilder = new ExoPlayer.Builder(context);
DefaultRenderersFactory defaultRendererFactory = new DefaultRenderersFactory(context);
defaultRendererFactory.setEnableDecoderFallback(true);
defaultRendererFactory.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON);
defaultRendererFactory.setExtensionRendererMode(determineExoPlayerExtensionRendererMode());
exoPlayerBuilder.setRenderersFactory(defaultRendererFactory);
DefaultTrackSelector trackSelector = new DefaultTrackSelector(context);
@@ -185,6 +208,8 @@ public class VideoManager {
httpDataSourceFactory.setReadTimeoutMs(30 * 1000);
DefaultDataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(context, httpDataSourceFactory);
exoPlayerBuilder.setMediaSourceFactory(new DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory));
extractorsFactory.setConstantBitrateSeekingEnabled(true);
extractorsFactory.setConstantBitrateSeekingAlwaysEnabled(true);
return exoPlayerBuilder;
}
@@ -193,20 +218,20 @@ public class VideoManager {
return mExoPlayer != null;
}
public int getZoomMode() {
public @NonNull ZoomMode getZoomMode() {
return mZoomMode;
}
public void setZoom(int mode) {
public void setZoom(@NonNull ZoomMode mode) {
mZoomMode = mode;
switch (mode) {
case ZOOM_FIT:
case FIT:
mExoPlayerView.setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FIT);
break;
case ZOOM_AUTO_CROP:
case AUTO_CROP:
mExoPlayerView.setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_ZOOM);
break;
case ZOOM_STRETCH:
case STRETCH:
mExoPlayerView.setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FILL);
break;
}
@@ -268,7 +293,6 @@ public class VideoManager {
public void stopPlayback() {
if (mExoPlayer != null) {
mExoPlayer.stop();
disableSubs();
mExoPlayer.setTrackSelectionParameters(mExoPlayer.getTrackSelectionParameters()
.buildUpon()
@@ -297,7 +321,15 @@ public class VideoManager {
return pos;
}
public void setVideoPath(@Nullable String path) {
private int getSubtitleSelectionFlags(MediaStream mediaStream) {
int flags = 0;
if (mediaStream.isDefault()) flags &= C.SELECTION_FLAG_DEFAULT;
if (mediaStream.isForced()) flags &= C.SELECTION_FLAG_FORCED;
return flags;
}
public void setMediaStreamInfo(ApiClient api, StreamInfo streamInfo) {
String path = streamInfo.getMediaUrl();
if (path == null) {
Timber.w("Video path is null cannot continue");
return;
@@ -305,23 +337,37 @@ public class VideoManager {
Timber.i("Video path set to: %s", path);
try {
mExoPlayer.setMediaItem(MediaItem.fromUri(Uri.parse(path)));
// Add external subtitles
List<MediaItem.SubtitleConfiguration> subtitleConfigurations = new ArrayList<>();
for (MediaStream mediaStream : streamInfo.getMediaSource().getMediaStreams()) {
if (mediaStream.getType() != MediaStreamType.SUBTITLE) continue;
if (mediaStream.getDeliveryMethod() == SubtitleDeliveryMethod.EXTERNAL) {
Uri subtitleUri = Uri.parse(api.createUrl(mediaStream.getDeliveryUrl(), Collections.emptyMap(), Collections.emptyMap(), true));
MediaItem.SubtitleConfiguration subtitleConfiguration = new MediaItem.SubtitleConfiguration.Builder(subtitleUri)
.setId("JF_EXTERNAL:" + String.valueOf(mediaStream.getIndex()))
.setMimeType(SubtitleKt.getFfmpegSubtitleMimeType(mediaStream.getCodec()))
.setLanguage(mediaStream.getLanguage())
.setLabel(mediaStream.getDisplayTitle())
.setSelectionFlags(getSubtitleSelectionFlags(mediaStream))
.build();
Timber.i("Adding subtitle track %s of type %s", subtitleConfiguration.uri, subtitleConfiguration.mimeType);
subtitleConfigurations.add(subtitleConfiguration);
}
}
MediaItem mediaItem = new MediaItem.Builder()
.setUri(Uri.parse(path))
.setSubtitleConfigurations(subtitleConfigurations)
.build();
mExoPlayer.setMediaItem(mediaItem);
mExoPlayer.prepare();
} catch (IllegalStateException e) {
Timber.e(e, "Unable to set video path. Probably backing out.");
}
}
public void disableSubs() {
if (!isInitialized())
return;
mExoPlayer.setTrackSelectionParameters(mExoPlayer.getTrackSelectionParameters()
.buildUpon()
.setDisabledTrackTypes(ImmutableSet.of(C.TRACK_TYPE_TEXT))
.build());
}
private int offsetStreamIndex(int index, boolean adjustByAdding, boolean indexStartsAtOne, @Nullable List<org.jellyfin.sdk.model.api.MediaStream> allStreams) {
if (index < 0 || allStreams == null)
return -1;
@@ -370,7 +416,11 @@ public class VideoManager {
if (trackFormat.id != null) {
int id;
try {
id = Integer.parseInt(trackFormat.id);
if (trackFormat.id.contains(":")) {
id = Integer.parseInt(trackFormat.id.split(":")[1]);
} else {
id = Integer.parseInt(trackFormat.id);
}
} catch (NumberFormatException e) {
Timber.d("failed to parse track ID [%s]", trackFormat.id);
break;
@@ -399,13 +449,9 @@ public class VideoManager {
int chosenTrackType = streamType == org.jellyfin.sdk.model.api.MediaStreamType.SUBTITLE ? C.TRACK_TYPE_TEXT : C.TRACK_TYPE_AUDIO;
// Make sure the index is present
Optional<MediaStream> candidateOptional = allStreams.stream().filter(stream -> stream.getIndex() == index).findFirst();
Optional<MediaStream> candidateOptional = allStreams.stream().filter(stream -> stream.getIndex() == index && !stream.isExternal() && stream.getType() == streamType).findFirst();
if (!candidateOptional.isPresent()) return false;
org.jellyfin.sdk.model.api.MediaStream candidate = candidateOptional.get();
if (candidate.isExternal() || candidate.getType() != streamType)
return false;
int exoTrackID = offsetStreamIndex(index, false, true, allStreams);
if (exoTrackID < 0)
return false;
@@ -435,14 +481,18 @@ public class VideoManager {
Format trackFormat = group.getFormat(i);
Timber.d("track %s group %s/%s trackType %s label %s mime %s isSelected %s isSupported %s",
trackFormat.id, i+1, group.length, trackType, trackFormat.label, trackFormat.sampleMimeType, isSelected, isSupported);
trackFormat.id, i + 1, group.length, trackType, trackFormat.label, trackFormat.sampleMimeType, isSelected, isSupported);
if (trackType != chosenTrackType || trackFormat.id == null)
continue;
int id;
try {
id = Integer.parseInt(trackFormat.id);
if (trackFormat.id.contains(":")) {
id = Integer.parseInt(trackFormat.id.split(":")[1]);
} else {
id = Integer.parseInt(trackFormat.id);
}
if (id != exoTrackID)
continue;
} catch (NumberFormatException e) {
@@ -471,8 +521,6 @@ public class VideoManager {
try {
TrackSelectionParameters.Builder mExoPlayerSelectionParams = mExoPlayer.getTrackSelectionParameters().buildUpon();
mExoPlayerSelectionParams.setOverrideForType(new TrackSelectionOverride(matchedGroup, 0));
if (streamType == MediaStreamType.SUBTITLE)
mExoPlayerSelectionParams.setDisabledTrackTypes(ImmutableSet.of(C.TRACK_TYPE_NONE));
mExoPlayer.setTrackSelectionParameters(mExoPlayerSelectionParams.build());
} catch (Exception e) {
Timber.d("Error setting track selection");
@@ -481,7 +529,7 @@ public class VideoManager {
return true;
}
public float getPlaybackSpeed(){
public float getPlaybackSpeed() {
if (!isInitialized()) {
return 1.0f;
} else {
@@ -554,6 +602,7 @@ public class VideoManager {
}
private Runnable progressLoop;
private void startProgressLoop() {
stopProgressLoop();
progressLoop = new Runnable() {

View File

@@ -1,21 +1,106 @@
package org.jellyfin.androidtv.ui.playback.overlay
import android.content.Context
import androidx.leanback.widget.PlaybackSeekDataProvider
import coil3.ImageLoader
import coil3.network.NetworkHeaders
import coil3.network.httpHeaders
import coil3.request.Disposable
import coil3.request.ImageRequest
import coil3.request.transformations
import coil3.size.Size
import coil3.toBitmap
import org.jellyfin.androidtv.util.coil.SubsetTransformation
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.trickplayApi
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import kotlin.math.ceil
import kotlin.math.min
class CustomSeekProvider(
private val videoPlayerAdapter: VideoPlayerAdapter,
private val imageLoader: ImageLoader,
private val api: ApiClient,
private val context: Context,
private val trickPlayEnabled: Boolean,
private val forwardTime: Long
) : PlaybackSeekDataProvider() {
companion object {
private const val SEEK_LENGTH = 30000L
}
private val imageRequests = mutableMapOf<Int, Disposable>()
override fun getSeekPositions(): LongArray {
if (!videoPlayerAdapter.canSeek()) return LongArray(0)
val duration = videoPlayerAdapter.duration
val size = ceil(duration.toDouble() / SEEK_LENGTH.toDouble()).toInt() + 1
return LongArray(size) { i -> min(i * SEEK_LENGTH, duration) }
val size = ceil(duration.toDouble() / forwardTime.toDouble()).toInt() + 1
return LongArray(size) { i -> min(i * forwardTime, duration) }
}
override fun getThumbnail(index: Int, callback: ResultCallback) {
if (!trickPlayEnabled) return
val currentRequest = imageRequests[index]
if (currentRequest?.isDisposed == false) currentRequest.dispose()
val item = videoPlayerAdapter.currentlyPlayingItem
val mediaSource = videoPlayerAdapter.currentMediaSource
val mediaSourceId = mediaSource?.id?.toUUIDOrNull()
if (item == null || mediaSource == null || mediaSourceId == null) return
val trickPlayResolutions = item.trickplay?.get(mediaSource.id)
val trickPlayInfo = trickPlayResolutions?.values?.firstOrNull()
if (trickPlayInfo == null) return
val currentTimeMs = (index * forwardTime).coerceIn(0, videoPlayerAdapter.duration)
val currentTile = currentTimeMs.floorDiv(trickPlayInfo.interval).toInt()
val tileSize = trickPlayInfo.tileWidth * trickPlayInfo.tileHeight
val tileOffset = currentTile % tileSize
val tileIndex = currentTile / tileSize
val tileOffsetX = tileOffset % trickPlayInfo.tileWidth
val tileOffsetY = tileOffset / trickPlayInfo.tileWidth
val offsetX = tileOffsetX * trickPlayInfo.width
val offsetY = tileOffsetY * trickPlayInfo.height
val url = api.trickplayApi.getTrickplayTileImageUrl(
itemId = item.id,
width = trickPlayInfo.width,
index = tileIndex,
mediaSourceId = mediaSourceId,
)
imageRequests[index] = imageLoader.enqueue(ImageRequest.Builder(context).apply {
data(url)
size(Size.ORIGINAL)
httpHeaders(NetworkHeaders.Builder().apply {
set(
key = "Authorization",
value = AuthorizationHeaderBuilder.buildHeader(
api.clientInfo.name,
api.clientInfo.version,
api.deviceInfo.id,
api.deviceInfo.name,
api.accessToken
)
)
}.build())
transformations(SubsetTransformation(offsetX, offsetY, trickPlayInfo.width, trickPlayInfo.height))
target(
onSuccess = { image ->
val bitmap = image.toBitmap()
callback.onThumbnailLoaded(bitmap, index)
}
)
}.build())
}
override fun reset() {
for (request in imageRequests.values) {
if (!request.isDisposed) request.dispose()
}
imageRequests.clear()
}
}

View File

@@ -3,13 +3,20 @@ package org.jellyfin.androidtv.ui.playback.overlay;
import static org.koin.java.KoinJavaComponent.inject;
import android.os.Bundle;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.leanback.app.PlaybackSupportFragment;
import org.jellyfin.androidtv.preference.UserPreferences;
import org.jellyfin.androidtv.preference.UserSettingPreferences;
import org.jellyfin.androidtv.ui.playback.CustomPlaybackOverlayFragment;
import org.jellyfin.androidtv.ui.playback.PlaybackController;
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer;
import org.jellyfin.sdk.api.client.ApiClient;
import coil3.ImageLoader;
import kotlin.Lazy;
import timber.log.Timber;
@@ -18,6 +25,10 @@ public class LeanbackOverlayFragment extends PlaybackSupportFragment {
private VideoPlayerAdapter playerAdapter;
private boolean shouldShowOverlay = true;
private Lazy<PlaybackControllerContainer> playbackControllerContainer = inject(PlaybackControllerContainer.class);
private final Lazy<UserSettingPreferences> userSettingPreferences = inject(UserSettingPreferences.class);
private Lazy<ImageLoader> imageLoader = inject(ImageLoader.class);
private Lazy<ApiClient> api = inject(ApiClient.class);
private Lazy<UserPreferences> userPreferences = inject(UserPreferences.class);
@Override
public void onCreate(Bundle savedInstanceState) {
@@ -36,6 +47,13 @@ public class LeanbackOverlayFragment extends PlaybackSupportFragment {
playerGlue.setHost(new CustomPlaybackFragmentGlueHost(this));
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
super.hideControlsOverlay(false);
}
public void initFromView(CustomPlaybackOverlayFragment customPlaybackOverlayFragment) {
playerGlue.setInitialPlaybackDrawable();
playerAdapter.setMasterOverlayFragment(customPlaybackOverlayFragment);
@@ -45,9 +63,16 @@ public class LeanbackOverlayFragment extends PlaybackSupportFragment {
public void showControlsOverlay(boolean runAnimation) {
if (shouldShowOverlay) {
super.showControlsOverlay(runAnimation);
playerAdapter.getMasterOverlayFragment().show();
}
}
@Override
public void hideControlsOverlay(boolean runAnimation) {
super.hideControlsOverlay(runAnimation);
playerAdapter.getMasterOverlayFragment().hide();
}
public void updateCurrentPosition() {
playerAdapter.updateCurrentPosition();
updatePlayState();
@@ -76,7 +101,10 @@ public class LeanbackOverlayFragment extends PlaybackSupportFragment {
playerGlue.invalidatePlaybackControls();
playerGlue.setSeekEnabled(playerAdapter.canSeek());
playerGlue.setSeekProvider(playerAdapter.canSeek() ? new CustomSeekProvider(playerAdapter) : null);
long skipForwardLength = userSettingPreferences.getValue().get(UserSettingPreferences.Companion.getSkipForwardLength()).longValue();
boolean enableTrickPlay = userPreferences.getValue().get(UserPreferences.Companion.getTrickPlayEnabled());
playerGlue.setSeekProvider(playerAdapter.canSeek() ? new CustomSeekProvider(playerAdapter, imageLoader.getValue(), api.getValue(), requireContext(), enableTrickPlay, skipForwardLength) : null);
recordingStateChanged();
playerAdapter.updateDuration();
}
@@ -88,7 +116,9 @@ public class LeanbackOverlayFragment extends PlaybackSupportFragment {
@Override
public void onPause() {
super.onPause();
playerAdapter.getMasterOverlayFragment().onPause();
if (playerAdapter != null) {
playerAdapter.getMasterOverlayFragment().onPause();
}
}
public CustomPlaybackTransportControlGlue getPlayerGlue() {

View File

@@ -0,0 +1,137 @@
package org.jellyfin.androidtv.ui.playback.overlay
import android.content.Context
import android.util.AttributeSet
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepository
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun SkipOverlayComposable(
visible: Boolean,
) {
Box(
contentAlignment = Alignment.BottomEnd,
modifier = Modifier
.padding(48.dp, 48.dp)
) {
AnimatedVisibility(visible, enter = fadeIn(), exit = fadeOut()) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background(colorResource(R.color.popup_menu_background).copy(alpha = 0.6f))
.padding(10.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(R.drawable.ic_control_select),
contentDescription = null,
)
Text(
text = stringResource(R.string.segment_action_skip),
color = colorResource(R.color.button_default_normal_text),
fontSize = 18.sp,
)
}
}
}
}
class SkipOverlayView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
) : AbstractComposeView(context, attrs, defStyle) {
private val _currentPosition = MutableStateFlow(Duration.ZERO)
private val _targetPosition = MutableStateFlow<Duration?>(null)
private val _skipUiEnabled = MutableStateFlow(true)
var currentPosition: Duration
get() = _currentPosition.value
set(value) {
_currentPosition.value = value
}
var currentPositionMs: Long
get() = _currentPosition.value.inWholeMilliseconds
set(value) {
_currentPosition.value = value.milliseconds
}
var targetPosition: Duration?
get() = _targetPosition.value
set(value) {
_targetPosition.value = value
}
var targetPositionMs: Long?
get() = _targetPosition.value?.inWholeMilliseconds
set(value) {
_targetPosition.value = value?.milliseconds
}
var skipUiEnabled: Boolean
get() = _skipUiEnabled.value
set(value) {
_skipUiEnabled.value = value
}
val visible: Boolean
get() {
val enabled = _skipUiEnabled.value
val targetPosition = _targetPosition.value
val currentPosition = _currentPosition.value
return enabled && targetPosition != null && currentPosition <= (targetPosition - MediaSegmentRepository.SkipMinDuration)
}
@Composable
override fun Content() {
val skipUiEnabled by _skipUiEnabled.collectAsState()
val currentPosition by _currentPosition.collectAsState()
val targetPosition by _targetPosition.collectAsState()
val visible by remember(skipUiEnabled, currentPosition, targetPosition) {
derivedStateOf { visible }
}
// Auto hide
LaunchedEffect(skipUiEnabled, targetPosition) {
delay(MediaSegmentRepository.AskToSkipAutoHideDuration)
_targetPosition.value = null
}
SkipOverlayComposable(visible)
}
}

View File

@@ -9,6 +9,7 @@ import org.jellyfin.androidtv.ui.playback.PlaybackController;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.androidtv.util.apiclient.StreamHelper;
import org.jellyfin.sdk.model.api.ChapterInfo;
import org.jellyfin.sdk.model.api.MediaSourceInfo;
import org.koin.java.KoinJavaComponent;
import java.util.List;
@@ -164,6 +165,10 @@ public class VideoPlayerAdapter extends PlayerAdapter {
return playbackController.getCurrentlyPlayingItem();
}
MediaSourceInfo getCurrentMediaSource() {
return playbackController.getCurrentMediaSource();
}
boolean hasChapters() {
org.jellyfin.sdk.model.api.BaseItemDto item = getCurrentlyPlayingItem();
List<ChapterInfo> chapters = item.getChapters();

View File

@@ -8,8 +8,9 @@ import android.widget.Toast
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.playback.PlaybackController
import org.jellyfin.androidtv.ui.playback.overlay.CustomPlaybackTransportControlGlue
import org.jellyfin.androidtv.ui.playback.overlay.LeanbackOverlayFragment
import org.jellyfin.androidtv.ui.playback.overlay.VideoPlayerAdapter
import org.jellyfin.androidtv.ui.playback.setSubtitleIndex
import org.jellyfin.sdk.model.api.MediaStreamType
import timber.log.Timber
class ClosedCaptionsAction(
@@ -35,21 +36,24 @@ class ClosedCaptionsAction(
videoPlayerAdapter.leanbackOverlayFragment.setFading(false)
PopupMenu(context, view, Gravity.END).apply {
with(menu) {
for (sub in playbackController.subtitleStreams) {
add(0, sub.index, sub.index, sub.displayTitle).apply {
isChecked = sub.index == playbackController.subtitleStreamIndex
}
var order = 0
add(0, -1, order++, context.getString(R.string.lbl_none)).apply {
isChecked = playbackController.subtitleStreamIndex == -1
}
add(0, -1, 0, context.getString(R.string.lbl_none)).apply {
isChecked = playbackController.subtitleStreamIndex == -1
for (sub in playbackController.currentMediaSource.mediaStreams.orEmpty()) {
if (sub.type != MediaStreamType.SUBTITLE) continue
add(0, sub.index, order++, sub.displayTitle).apply {
isChecked = sub.index == playbackController.subtitleStreamIndex
}
}
setGroupCheckable(0, true, false)
}
setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
setOnMenuItemClickListener { item ->
playbackController.switchSubtitleStream(item.itemId)
playbackController.setSubtitleIndex(item.itemId)
true
}
}.show()

View File

@@ -10,7 +10,6 @@ import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.ui.playback.PlaybackController
import org.jellyfin.androidtv.ui.playback.VideoQualityController
import org.jellyfin.androidtv.ui.playback.overlay.CustomPlaybackTransportControlGlue
import org.jellyfin.androidtv.ui.playback.overlay.LeanbackOverlayFragment
import org.jellyfin.androidtv.ui.playback.overlay.VideoPlayerAdapter
class SelectQualityAction(
@@ -39,8 +38,11 @@ class SelectQualityAction(
}
menu.setGroupCheckable(0, true, true)
menu.getItem(qualityProfiles.keys.indexOf(qualityController.currentQuality))?.let { item ->
item.isChecked = true
val currentQualityIndex = qualityProfiles.keys.indexOf(qualityController.currentQuality)
if (currentQualityIndex != -1) {
menu.getItem(currentQualityIndex)?.let { item ->
item.isChecked = true
}
}
setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }

View File

@@ -3,13 +3,12 @@ package org.jellyfin.androidtv.ui.playback.overlay.action
import android.content.Context
import android.view.Gravity
import android.view.View
import android.widget.PopupMenu
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.preference.constant.ZoomMode
import org.jellyfin.androidtv.ui.playback.PlaybackController
import org.jellyfin.androidtv.ui.playback.VideoManager
import org.jellyfin.androidtv.ui.playback.overlay.CustomPlaybackTransportControlGlue
import org.jellyfin.androidtv.ui.playback.overlay.LeanbackOverlayFragment
import org.jellyfin.androidtv.ui.playback.overlay.VideoPlayerAdapter
import org.jellyfin.androidtv.util.popupMenu
class ZoomAction(
context: Context,
@@ -26,43 +25,27 @@ class ZoomAction(
view: View,
) {
videoPlayerAdapter.leanbackOverlayFragment.setFading(false)
return PopupMenu(context, view, Gravity.END).apply {
with(menu) {
add(
0,
VideoManager.ZOOM_AUTO_CROP,
VideoManager.ZOOM_AUTO_CROP,
context.getString(R.string.lbl_auto_crop)
).apply {
isChecked = playbackController.zoomMode == VideoManager.ZOOM_AUTO_CROP
}
add(
0,
VideoManager.ZOOM_FIT,
VideoManager.ZOOM_FIT,
context.getString(R.string.lbl_fit)
).apply {
isChecked = playbackController.zoomMode == VideoManager.ZOOM_FIT
}
add(
0,
VideoManager.ZOOM_STRETCH,
VideoManager.ZOOM_STRETCH,
context.getString(R.string.lbl_stretch)
).apply {
isChecked = playbackController.zoomMode == VideoManager.ZOOM_STRETCH
}
setGroupCheckable(0, true, true)
val popup = popupMenu(context, view, Gravity.END) {
item(context.getString(R.string.lbl_fit)) {
playbackController.setZoom(ZoomMode.FIT)
}.apply {
isChecked = playbackController.zoomMode == ZoomMode.FIT
}
setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
setOnMenuItemClickListener { item ->
playbackController.setZoom(item.itemId)
true
item(context.getString(R.string.lbl_auto_crop)) {
playbackController.setZoom(ZoomMode.AUTO_CROP)
}.apply {
isChecked = playbackController.zoomMode == ZoomMode.AUTO_CROP
}
}.show()
item(context.getString(R.string.lbl_stretch)) {
playbackController.setZoom(ZoomMode.STRETCH)
}.apply {
isChecked = playbackController.zoomMode == ZoomMode.STRETCH
}
}
popup.menu.setGroupCheckable(0, true, true)
popup.setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
popup.show()
}
}

View File

@@ -16,6 +16,7 @@ import org.jellyfin.androidtv.ui.ScreensaverViewModel
import org.jellyfin.androidtv.ui.playback.VideoQueueManager
import org.jellyfin.playback.core.PlaybackManager
import org.jellyfin.playback.core.model.PlayState
import org.jellyfin.playback.core.queue.queue
import org.jellyfin.playback.core.ui.PlayerSubtitleView
import org.jellyfin.playback.core.ui.PlayerSurfaceView
import org.jellyfin.sdk.api.client.ApiClient
@@ -43,17 +44,18 @@ class PlaybackRewriteFragment : Fragment() {
super.onCreate(savedInstanceState)
// Create a queue from the items added to the legacy video queue
val queue = RewriteMediaManager.BaseItemQueue(api)
queue.items.addAll(videoQueueManager.getCurrentVideoQueue())
Timber.i("Created a queue with ${queue.items.size} items")
playbackManager.state.queue.replaceQueue(queue)
val queueSupplier = RewriteMediaManager.BaseItemQueueSupplier(api)
queueSupplier.items.addAll(videoQueueManager.getCurrentVideoQueue())
Timber.i("Created a queue with ${queueSupplier.items.size} items")
playbackManager.queue.clear()
playbackManager.queue.addSupplier(queueSupplier)
// Set position
val position = arguments?.getInt(EXTRA_POSITION) ?: 0
if (position != 0) {
lifecycleScope.launch {
Timber.i("Skipping to queue item $position")
playbackManager.state.queue.setIndex(position, false)
playbackManager.queue.setIndex(position, false)
}
}

View File

@@ -23,8 +23,9 @@ import org.jellyfin.playback.core.PlaybackManager
import org.jellyfin.playback.core.model.PlayState
import org.jellyfin.playback.core.model.PlaybackOrder
import org.jellyfin.playback.core.model.RepeatMode
import org.jellyfin.playback.core.queue.Queue
import org.jellyfin.playback.core.queue.QueueEntry
import org.jellyfin.playback.core.queue.queue
import org.jellyfin.playback.core.queue.supplier.QueueSupplier
import org.jellyfin.playback.jellyfin.queue.baseItem
import org.jellyfin.playback.jellyfin.queue.createBaseItemQueueEntry
import org.jellyfin.sdk.api.client.ApiClient
@@ -39,7 +40,7 @@ class RewriteMediaManager(
private val navigationRepository: NavigationRepository,
private val playbackManager: PlaybackManager,
) : MediaManager {
private val queue = BaseItemQueue(api)
private val queueSupplier = BaseItemQueueSupplier(api)
override fun hasAudioQueueItems(): Boolean = currentAudioQueue.size() > 0 && currentAudioItem != null
@@ -47,20 +48,19 @@ class RewriteMediaManager(
get() = currentAudioQueue.size()
override val currentAudioQueuePosition: Int
get() = if ((playbackManager.state.queue.entryIndex.value) >= 0) 0 else -1
get() = if ((playbackManager.queue.entryIndex.value) >= 0) 0 else -1
override val currentAudioPosition: Long
get() = playbackManager.state.positionInfo.active.inWholeMilliseconds
override val currentAudioQueueDisplayPosition: String
get() = (currentAudioQueuePosition + 1).toString()
get() = (playbackManager.queue.entryIndex.value + 1).toString()
override val currentAudioQueueDisplaySize: String
get() = ((playbackManager.state.queue.current.value as? BaseItemQueue)?.items?.size
?: currentAudioQueue.size()).toString()
get() = playbackManager.queue.estimatedSize.toString()
override val currentAudioItem: BaseItemDto?
get() = playbackManager.state.queue.entry.value?.baseItem
get() = playbackManager.queue.entry.value?.baseItem
?.takeIf { it.mediaType == MediaType.AUDIO }
override fun toggleRepeat(): Boolean {
@@ -108,12 +108,14 @@ class RewriteMediaManager(
val firstItem = currentAudioQueue.get(0) as? AudioQueueBaseRowItem
firstItem?.playing = playState == PlayState.PLAYING
onPlaybackStateChange(when (playState) {
PlayState.STOPPED -> PlaybackController.PlaybackState.IDLE
PlayState.PLAYING -> PlaybackController.PlaybackState.PLAYING
PlayState.PAUSED -> PlaybackController.PlaybackState.PAUSED
PlayState.ERROR -> PlaybackController.PlaybackState.ERROR
}, currentAudioItem)
onPlaybackStateChange(
when (playState) {
PlayState.STOPPED -> PlaybackController.PlaybackState.IDLE
PlayState.PLAYING -> PlaybackController.PlaybackState.PLAYING
PlayState.PAUSED -> PlaybackController.PlaybackState.PAUSED
PlayState.ERROR -> PlaybackController.PlaybackState.ERROR
}, currentAudioItem
)
}
}.launchIn(this)
@@ -126,27 +128,28 @@ class RewriteMediaManager(
}
}
playbackManager.state.queue.current.onEach {
playbackManager.queue.entry.onEach { entry ->
val baseItem = entry?.baseItem
notifyListeners {
onQueueStatusChanged(hasAudioQueueItems())
onQueueStatusChanged(baseItem?.mediaType == MediaType.AUDIO)
}
}.launchIn(this)
playbackManager.state.queue.entry.onEach { updateAdapter() }.launchIn(this)
playbackManager.queue.entry.onEach { updateAdapter() }.launchIn(this)
}
private fun updateAdapter() {
// Get all items as BaseRowItem
val items = queue
val items = queueSupplier
.items
// Map to audio queue items
.mapIndexed { index, item ->
AudioQueueBaseRowItem(item).apply {
playing = playbackManager.state.queue.entryIndex.value == index
playing = playbackManager.queue.entryIndex.value == index
}
}
// Remove items before currently playing item
.drop(max(0, playbackManager.state.queue.entryIndex.value))
.drop(max(0, playbackManager.queue.entryIndex.value))
// Update item row
currentAudioQueue.replaceAll(
@@ -185,32 +188,26 @@ class RewriteMediaManager(
override fun addToAudioQueue(items: List<BaseItemDto>) {
if (items.isEmpty()) return
val addIndex = when (playbackManager.state.playState.value) {
PlayState.PLAYING -> playbackManager.state.queue.entryIndex.value + 1
else -> 0
}
queueSupplier.items.addAll(items)
queue.items.addAll(addIndex, items)
if (
playbackManager.state.queue.current.value != queue ||
playbackManager.state.playState.value != PlayState.PLAYING
) {
if (playbackManager.state.playState.value != PlayState.PLAYING) {
playbackManager.state.setPlaybackOrder(if (isShuffleMode) PlaybackOrder.SHUFFLE else PlaybackOrder.DEFAULT)
playbackManager.state.play(queue)
playbackManager.queue.clear()
playbackManager.queue.addSupplier(queueSupplier)
playbackManager.state.play()
}
updateAdapter()
}
override fun removeFromAudioQueue(item: BaseItemDto) {
val index = queue.items.indexOf(item)
val index = queueSupplier.items.indexOf(item)
if (index == -1) return
// Disallow removing currently playing item (legacy UI cannot keep up)
if (playbackManager.state.queue.entryIndex.value == index) return
if (playbackManager.queue.entryIndex.value == index) return
queue.items.removeAt(index)
queueSupplier.items.removeAt(index)
updateAdapter()
}
@@ -219,19 +216,21 @@ class RewriteMediaManager(
override fun playNow(context: Context, items: List<BaseItemDto>, position: Int, shuffle: Boolean) {
val filteredItems = items.drop(position)
queue.items.clear()
queue.items.addAll(filteredItems)
queueSupplier.items.clear()
queueSupplier.items.addAll(filteredItems)
playbackManager.state.setPlaybackOrder(if (shuffle) PlaybackOrder.SHUFFLE else PlaybackOrder.DEFAULT)
playbackManager.state.play(queue)
playbackManager.queue.clear()
playbackManager.queue.addSupplier(queueSupplier)
playbackManager.state.play()
navigationRepository.navigate(Destinations.nowPlaying)
}
override fun playFrom(item: BaseItemDto): Boolean {
val index = queue.items.indexOf(item)
val index = queueSupplier.items.indexOf(item)
if (index == -1) return false
return runBlocking {
playbackManager.state.queue.setIndex(index) != null
playbackManager.queue.setIndex(index) != null
}
}
@@ -245,23 +244,23 @@ class RewriteMediaManager(
}
override fun hasNextAudioItem(): Boolean = runBlocking {
playbackManager.state.queue.peekNext() != null
playbackManager.queue.peekNext() != null
}
override fun hasPrevAudioItem(): Boolean = playbackManager.state.queue.entryIndex.value > 0
override fun hasPrevAudioItem(): Boolean = playbackManager.queue.entryIndex.value > 0
override fun nextAudioItem(): Int {
runBlocking { playbackManager.state.queue.next() }
runBlocking { playbackManager.queue.next() }
notifyListeners { onQueueStatusChanged(hasAudioQueueItems()) }
return playbackManager.state.queue.entryIndex.value
return playbackManager.queue.entryIndex.value
}
override fun prevAudioItem(): Int {
runBlocking { playbackManager.state.queue.previous() }
runBlocking { playbackManager.queue.previous() }
notifyListeners { onQueueStatusChanged(hasAudioQueueItems()) }
return playbackManager.state.queue.entryIndex.value
return playbackManager.queue.entryIndex.value
}
override fun stopAudio(releasePlayer: Boolean) {
@@ -275,12 +274,12 @@ class RewriteMediaManager(
}
/**
* A simple [Queue] implementation for compatibility with existing UI/playback code. It contains
* A simple [QueueSupplier] implementation for compatibility with existing UI/playback code. It contains
* a mutable BaseItemDto list that is used to retrieve items from.
*/
class BaseItemQueue(
class BaseItemQueueSupplier(
private val api: ApiClient,
) : Queue {
) : QueueSupplier {
val items = mutableListOf<BaseItemDto>()
override val size: Int

View File

@@ -0,0 +1,25 @@
package org.jellyfin.androidtv.ui.playback.segment
import org.jellyfin.androidtv.R
import org.jellyfin.preference.PreferenceEnum
enum class MediaSegmentAction(
override val nameRes: Int,
) : PreferenceEnum {
/**
* Don't take any action for this segment.
*/
NOTHING(R.string.segment_action_nothing),
/**
* Seek to the end of this segment (endTicks). If the duration of this segment is shorter than 1 second it should do nothing to avoid
* lagg. The skip action will only execute when playing over the segment start, not when seeking into the segment block.
*/
SKIP(R.string.segment_action_skip),
/**
* Ask the user if they want to skip this segment. When the user agrees this behaves like [SKIP]. Confirmation should only be asked for
* segments with a duration of at least 3 seconds to avoid UI flickering.
*/
ASK_TO_SKIP(R.string.segment_action_ask_to_skip),
}

View File

@@ -0,0 +1,110 @@
package org.jellyfin.androidtv.ui.playback.segment
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.util.sdk.duration
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaSegmentType
import kotlin.time.Duration.Companion.seconds
interface MediaSegmentRepository {
companion object {
/**
* All media segments currently supported by the app. The order of these is used for the preferences UI.
*/
val SupportedTypes = listOf(
MediaSegmentType.INTRO,
MediaSegmentType.OUTRO,
MediaSegmentType.PREVIEW,
MediaSegmentType.RECAP,
MediaSegmentType.COMMERCIAL,
)
/**
* The minimum duration for a media segment to allow the [MediaSegmentAction.SKIP] action.
*/
val SkipMinDuration = 1.seconds
/**
* The minimum duration for a media segment to allow the [MediaSegmentAction.ASK_TO_SKIP] action.
*/
val AskToSkipMinDuration = 3.seconds
/**
* The duration to wait before automatically hiding the "ask to skip" UI.
*/
val AskToSkipAutoHideDuration = 8.seconds
}
fun getDefaultSegmentTypeAction(type: MediaSegmentType): MediaSegmentAction
fun setDefaultSegmentTypeAction(type: MediaSegmentType, action: MediaSegmentAction)
suspend fun getSegmentsForItem(item: BaseItemDto): List<MediaSegmentDto>
fun getMediaSegmentAction(segment: MediaSegmentDto): MediaSegmentAction
}
fun Map<MediaSegmentType, MediaSegmentAction>.toMediaSegmentActionsString() =
map { "${it.key.serialName}=${it.value.name}" }
.joinToString(",")
class MediaSegmentRepositoryImpl(
private val userPreferences: UserPreferences,
private val api: ApiClient,
) : MediaSegmentRepository {
private val mediaTypeActions = mutableMapOf<MediaSegmentType, MediaSegmentAction>()
init {
restoreMediaTypeActions()
}
private fun restoreMediaTypeActions() {
val restoredMediaTypeActions = userPreferences[UserPreferences.mediaSegmentActions]
.split(",")
.mapNotNull {
runCatching {
val (type, action) = it.split('=', limit = 2)
MediaSegmentType.fromName(type) to MediaSegmentAction.valueOf(action)
}.getOrNull()
}
mediaTypeActions.clear()
mediaTypeActions.putAll(restoredMediaTypeActions)
}
private fun saveMediaTypeActions() {
userPreferences[UserPreferences.mediaSegmentActions] = mediaTypeActions.toMediaSegmentActionsString()
}
override fun getDefaultSegmentTypeAction(type: MediaSegmentType): MediaSegmentAction {
// Always return no action for unsupported types
if (!MediaSegmentRepository.SupportedTypes.contains(type)) return MediaSegmentAction.NOTHING
return mediaTypeActions.getOrDefault(type, MediaSegmentAction.NOTHING)
}
override fun setDefaultSegmentTypeAction(type: MediaSegmentType, action: MediaSegmentAction) {
// Don't allow modifying actions for unsupported types
if (!MediaSegmentRepository.SupportedTypes.contains(type)) return
mediaTypeActions[type] = action
saveMediaTypeActions()
}
override fun getMediaSegmentAction(segment: MediaSegmentDto): MediaSegmentAction {
val action = getDefaultSegmentTypeAction(segment.type)
// Skip the skip action if timespan is too short
if (action == MediaSegmentAction.SKIP && segment.duration < MediaSegmentRepository.SkipMinDuration) return MediaSegmentAction.NOTHING
// Skip the ask to skip action if timespan is too short
if (action == MediaSegmentAction.ASK_TO_SKIP && segment.duration < MediaSegmentRepository.AskToSkipMinDuration) return MediaSegmentAction.NOTHING
return action
}
override suspend fun getSegmentsForItem(item: BaseItemDto): List<MediaSegmentDto> = runCatching {
api.mediaSegmentsApi.getItemSegments(
itemId = item.id,
includeSegmentTypes = MediaSegmentRepository.SupportedTypes,
).content.items
}.getOrDefault(emptyList())
}

View File

@@ -9,7 +9,6 @@ import java.util.UUID
class OptionsItemSeekbar(
private val context: Context
) : OptionsItemMutable<Int>() {
var content: String? = null
var min: Int = 0
var max: Int = 100
@@ -32,8 +31,9 @@ class OptionsItemSeekbar(
it.isEnabled = dependencyCheckFun() && enabled
it.isVisible = visible
it.title = title
it.min = min
// Max must be set before min because the setter of min checks if the value is below the current value of max
it.max = max
it.min = min
it.seekBarIncrement = increment
it.value = binder.get()
it.showSeekBarValue = true

View File

@@ -1,8 +1,8 @@
package org.jellyfin.androidtv.ui.preference.screen
import android.text.format.Formatter
import coil.ImageLoader
import coil.annotation.ExperimentalCoilApi
import coil3.ImageLoader
import coil3.annotation.ExperimentalCoilApi
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.preference.SystemPreferences
@@ -39,12 +39,6 @@ class DeveloperPreferencesScreen : OptionsFragment() {
}
}
checkbox {
setTitle(R.string.enable_reactive_homepage)
setContent(R.string.enable_playback_module_description)
bind(userPreferences, UserPreferences.homeReactive)
}
// Only show in debug mode
// some strings are hardcoded because these options don't show in beta/release builds
if (BuildConfig.DEVELOPMENT) {
@@ -56,6 +50,20 @@ class DeveloperPreferencesScreen : OptionsFragment() {
}
}
checkbox {
setTitle(R.string.preference_enable_trickplay)
setContent(R.string.enable_playback_module_description)
bind(userPreferences, UserPreferences.trickPlayEnabled)
}
checkbox {
setTitle(R.string.prefer_exoplayer_ffmpeg)
setContent(R.string.prefer_exoplayer_ffmpeg_content)
bind(userPreferences, UserPreferences.preferExoPlayerFfmpeg)
}
@OptIn(ExperimentalCoilApi::class)
action {
setTitle(R.string.clear_image_cache)

View File

@@ -21,15 +21,7 @@ class HomePreferencesScreen : OptionsFragment() {
category {
setTitle(R.string.home_sections)
arrayOf(
UserSettingPreferences.homesection0,
UserSettingPreferences.homesection1,
UserSettingPreferences.homesection2,
UserSettingPreferences.homesection3,
UserSettingPreferences.homesection4,
UserSettingPreferences.homesection5,
UserSettingPreferences.homesection6,
).forEachIndexed { index, section ->
userSettingPreferences.homesections.forEachIndexed { index, section ->
enum<HomeSectionType> {
title = getString(R.string.home_section_i, index + 1)
bind(userSettingPreferences, section)

View File

@@ -5,11 +5,14 @@ import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.constant.getQualityProfiles
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.preference.constant.RefreshRateSwitchingBehavior
import org.jellyfin.androidtv.preference.constant.ZoomMode
import org.jellyfin.androidtv.ui.preference.custom.DurationSeekBarPreference
import org.jellyfin.androidtv.ui.preference.dsl.OptionsFragment
import org.jellyfin.androidtv.ui.preference.dsl.checkbox
import org.jellyfin.androidtv.ui.preference.dsl.enum
import org.jellyfin.androidtv.ui.preference.dsl.list
import org.jellyfin.androidtv.ui.preference.dsl.optionsScreen
import org.jellyfin.androidtv.ui.preference.dsl.seekbar
import org.jellyfin.androidtv.util.TimeUtils
import org.koin.android.ext.android.inject
@@ -60,6 +63,27 @@ class PlaybackAdvancedPreferencesScreen : OptionsFragment() {
bind(userPreferences, UserPreferences.refreshRateSwitchingBehavior)
}
@Suppress("MagicNumber")
seekbar {
setTitle(R.string.video_start_delay)
min = 0
max = 5_000
increment = 250
valueFormatter = object : DurationSeekBarPreference.ValueFormatter() {
override fun display(value: Int): String = "${value.toDouble() / 1000}s"
}
bind {
get { userPreferences[UserPreferences.videoStartDelay].toInt() }
set { value -> userPreferences[UserPreferences.videoStartDelay] = value.toLong() }
default { UserPreferences.videoStartDelay.defaultValue.toInt() }
}
}
enum<ZoomMode> {
setTitle(R.string.default_video_zoom)
bind(userPreferences, UserPreferences.playerZoomMode)
}
checkbox{
setTitle(R.string.pref_external_player)
bind(userPreferences, UserPreferences.useExternalPlayer)

View File

@@ -2,9 +2,12 @@ package org.jellyfin.androidtv.ui.preference.screen
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.preference.UserSettingPreferences
import org.jellyfin.androidtv.preference.constant.AudioBehavior
import org.jellyfin.androidtv.preference.constant.NEXTUP_TIMER_DISABLED
import org.jellyfin.androidtv.preference.constant.NextUpBehavior
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentAction
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepository
import org.jellyfin.androidtv.ui.preference.custom.DurationSeekBarPreference
import org.jellyfin.androidtv.ui.preference.dsl.OptionsFragment
import org.jellyfin.androidtv.ui.preference.dsl.checkbox
@@ -13,10 +16,18 @@ import org.jellyfin.androidtv.ui.preference.dsl.enum
import org.jellyfin.androidtv.ui.preference.dsl.link
import org.jellyfin.androidtv.ui.preference.dsl.optionsScreen
import org.jellyfin.androidtv.ui.preference.dsl.seekbar
import org.jellyfin.preference.store.PreferenceStore
import org.jellyfin.sdk.model.api.MediaSegmentType
import org.koin.android.ext.android.inject
import kotlin.math.roundToInt
class PlaybackPreferencesScreen : OptionsFragment() {
private val userPreferences: UserPreferences by inject()
private val userSettingPreferences: UserSettingPreferences by inject()
private val mediaSegmentRepository: MediaSegmentRepository by inject()
override val stores: Array<PreferenceStore<*, *>>
get() = arrayOf(userSettingPreferences)
override val screen by optionsScreen {
setTitle(R.string.pref_playback)
@@ -55,6 +66,19 @@ class PlaybackPreferencesScreen : OptionsFragment() {
setContent(R.string.sum_enable_cinema_mode)
bind(userPreferences, UserPreferences.cinemaModeEnabled)
}
@Suppress("MagicNumber")
seekbar {
setTitle(R.string.skip_forward_length)
setContent(R.string.skip_forward_length)
min = 5_000
max = 30_000
increment = 5_000
valueFormatter = object : DurationSeekBarPreference.ValueFormatter() {
override fun display(value: Int) = "${value / 1000}s"
}
bind(userSettingPreferences, UserSettingPreferences.skipForwardLength)
}
}
category {
@@ -69,53 +93,96 @@ class PlaybackPreferencesScreen : OptionsFragment() {
category {
setTitle(R.string.pref_subtitles)
checkbox {
setTitle(R.string.pref_subtitles_background_title)
setContent(R.string.pref_subtitles_background_summary)
bind(userPreferences, UserPreferences.subtitlesBackgroundEnabled)
}
@Suppress("MagicNumber")
colorList {
setTitle(R.string.lbl_subtitle_fg)
setTitle(R.string.lbl_subtitle_text_color)
entries = mapOf(
0xFFFFFFFF to context.getString(R.string.color_white),
0XFF000000 to context.getString(R.string.color_black),
0xFF7F7F7F to context.getString(R.string.color_darkgrey),
0xFFC80000 to context.getString(R.string.color_red),
0xFF00C800 to context.getString(R.string.color_green),
0xFF0000C8 to context.getString(R.string.color_blue),
0xFFEEDC00 to context.getString(R.string.color_yellow),
0xFFD60080 to context.getString(R.string.color_pink),
0xFF009FDA to context.getString(R.string.color_cyan),
0xFFFFFFFFL to context.getString(R.string.color_white),
0XFF000000L to context.getString(R.string.color_black),
0xFF7F7F7FL to context.getString(R.string.color_darkgrey),
0xFFC80000L to context.getString(R.string.color_red),
0xFF00C800L to context.getString(R.string.color_green),
0xFF0000C8L to context.getString(R.string.color_blue),
0xFFEEDC00L to context.getString(R.string.color_yellow),
0xFFD60080L to context.getString(R.string.color_pink),
0xFF009FDAL to context.getString(R.string.color_cyan),
)
bind(userPreferences, UserPreferences.subtitlesTextColor)
}
@Suppress("MagicNumber")
seekbar {
setTitle(R.string.lbl_subtitle_stroke)
min = 0
max = 24
increment = 2
bind(userPreferences, UserPreferences.subtitleStrokeSize)
}
@Suppress("MagicNumber")
seekbar {
setTitle(R.string.lbl_subtitle_position)
min = 0
max = 300
increment = 10
bind(userPreferences, UserPreferences.subtitlePosition)
colorList {
setTitle(R.string.lbl_subtitle_background_color)
entries = mapOf(
0x00FFFFFFL to context.getString(R.string.lbl_none),
0xFFFFFFFFL to context.getString(R.string.color_white),
0XFF000000L to context.getString(R.string.color_black),
0xFF7F7F7FL to context.getString(R.string.color_darkgrey),
0xFFC80000L to context.getString(R.string.color_red),
0xFF00C800L to context.getString(R.string.color_green),
0xFF0000C8L to context.getString(R.string.color_blue),
0xFFEEDC00L to context.getString(R.string.color_yellow),
0xFFD60080L to context.getString(R.string.color_pink),
0xFF009FDAL to context.getString(R.string.color_cyan),
)
bind(userPreferences, UserPreferences.subtitlesBackgroundColor)
}
colorList {
setTitle(R.string.lbl_subtitle_text_stroke_color)
entries = mapOf(
0x00FFFFFFL to context.getString(R.string.lbl_none),
0xFFFFFFFFL to context.getString(R.string.color_white),
0XFF000000L to context.getString(R.string.color_black),
0xFF7F7F7FL to context.getString(R.string.color_darkgrey),
0xFFC80000L to context.getString(R.string.color_red),
0xFF00C800L to context.getString(R.string.color_green),
0xFF0000C8L to context.getString(R.string.color_blue),
0xFFEEDC00L to context.getString(R.string.color_yellow),
0xFFD60080L to context.getString(R.string.color_pink),
0xFF009FDAL to context.getString(R.string.color_cyan),
)
bind(userPreferences, UserPreferences.subtitleTextStrokeColor)
}
@Suppress("MagicNumber")
// Stored in floats (1f = 100%) but seekbar preference works with integers only
seekbar {
setTitle(R.string.pref_subtitles_size)
min = 10
max = 38
bind(userPreferences, UserPreferences.subtitlesSize)
min = 25 // 0.25f
max = 250 // 2.5f
increment = 25 // 0.25f
valueFormatter = object : DurationSeekBarPreference.ValueFormatter() {
override fun display(value: Int): String = "$value%"
}
bind {
get { (userPreferences[UserPreferences.subtitlesTextSize] * 100f).roundToInt() }
set { value -> userPreferences[UserPreferences.subtitlesTextSize] = value / 100f }
default { (UserPreferences.subtitlesTextSize.defaultValue * 100f).roundToInt() }
}
}
}
category {
setTitle(R.string.pref_mediasegment_actions)
for (segmentType in MediaSegmentRepository.SupportedTypes) {
enum<MediaSegmentAction> {
when (segmentType) {
MediaSegmentType.UNKNOWN -> R.string.segment_type_unknown
MediaSegmentType.COMMERCIAL -> R.string.segment_type_commercial
MediaSegmentType.PREVIEW -> R.string.segment_type_preview
MediaSegmentType.RECAP -> R.string.segment_type_recap
MediaSegmentType.OUTRO -> R.string.segment_type_outro
MediaSegmentType.INTRO -> R.string.segment_type_intro
}.let(::setTitle)
bind {
get { mediaSegmentRepository.getDefaultSegmentTypeAction(segmentType) }
set { value -> mediaSegmentRepository.setDefaultSegmentTypeAction(segmentType, value) }
default { MediaSegmentAction.NOTHING }
}
}
}
}

View File

@@ -104,7 +104,7 @@ public class CardPresenter extends Presenter {
} else if (imageType.equals(ImageType.THUMB)) {
aspect = ImageHelper.ASPECT_RATIO_16_9;
} else {
aspect = Utils.getSafeValue(imageHelper.getValue().getImageAspectRatio(itemDto, m.getPreferParentThumb()), ImageHelper.ASPECT_RATIO_7_9);
aspect = imageHelper.getValue().getImageAspectRatio(itemDto, m.getPreferParentThumb());
}
switch (itemDto.getType()) {
case AUDIO:
@@ -183,9 +183,9 @@ public class CardPresenter extends Presenter {
case MOVIE:
case VIDEO:
mDefaultCardImage = ContextCompat.getDrawable(mCardView.getContext(), R.drawable.tile_port_video);
showProgress = true;
if (imageType.equals(ImageType.POSTER))
aspect = ImageHelper.ASPECT_RATIO_2_3;
showProgress = true;
break;
default:
mDefaultCardImage = ContextCompat.getDrawable(mCardView.getContext(), R.drawable.tile_port_video);

View File

@@ -203,7 +203,7 @@ public class HorizontalGridPresenter extends Presenter {
vh.mItemBridgeAdapter.setAdapterListener(new ItemBridgeAdapter.AdapterListener() {
@Override
public void onBind(final ItemBridgeAdapter.ViewHolder itemViewHolder) {
// Only when having an OnItemClickListner, we attach the OnClickListener.
// Only when having an OnItemClickListener, we attach the OnClickListener.
if (getOnItemViewClickedListener() != null) {
final View itemView = itemViewHolder.getViewHolder().view;
itemView.setOnClickListener(new View.OnClickListener() {

View File

@@ -8,7 +8,7 @@ import androidx.recyclerview.widget.ListUpdateCallback
/**
* A leanback ObjectAdapter using a Kotlin list as backend. Implements Iterable to allow collection
* operations. And uses generics for strong typing. Uses a MutableList as internal stucture.
* operations. And uses generics for strong typing. Uses a MutableList as internal structure.
*/
open class MutableObjectAdapter<T : Any> : ObjectAdapter, Iterable<T> {
private val data = mutableListOf<T>()

View File

@@ -21,7 +21,7 @@ class MyDetailsOverviewRowPresenter(
fun setItem(row: MyDetailsOverviewRow) {
setTitle(row.item.name)
InfoLayoutHelper.addInfoRow(view.context, row.item, binding.fdMainInfoRow, false)
InfoLayoutHelper.addInfoRow(view.context, row.item, row.item.mediaSources?.getOrNull(row.selectedMediaSourceIndex), binding.fdMainInfoRow, false)
binding.fdGenreRow.text = row.item.genres?.joinToString(" / ")
binding.infoTitle1.text = row.infoItem1?.label

View File

@@ -0,0 +1,74 @@
package org.jellyfin.androidtv.ui.presentation
import android.util.TypedValue
import android.view.ViewGroup
import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import androidx.leanback.widget.Presenter
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.card.LegacyImageCardView
import org.jellyfin.androidtv.ui.itemhandling.BaseRowItem
import org.jellyfin.androidtv.util.ImageHelper
import org.jellyfin.sdk.model.api.ImageType
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class UserViewCardPresenter(
val small: Boolean,
) : Presenter(), KoinComponent {
private val imageHelper by inject<ImageHelper>()
inner class ViewHolder(
private val cardView: LegacyImageCardView,
) : Presenter.ViewHolder(cardView) {
fun setItem(rowItem: BaseRowItem?) {
val baseItem = rowItem?.baseItem
// Load image
val imageTag = baseItem?.imageTags?.get(ImageType.PRIMARY)
val imageBlurhash = imageTag?.let { baseItem.imageBlurHashes?.get(ImageType.PRIMARY)?.get(it) }
val imageUrl = imageTag?.let { imageHelper.getImageUrl(baseItem.id, ImageType.PRIMARY, it) }
cardView.mainImageView.load(
url = imageUrl,
blurHash = imageBlurhash,
placeholder = ContextCompat.getDrawable(cardView.context, R.drawable.tile_land_folder),
aspectRatio = ImageHelper.ASPECT_RATIO_16_9,
blurHashResolution = 32,
)
// Set title
cardView.setTitleText(rowItem?.getName(cardView.context))
// Set size
if (small) {
cardView.setMainImageDimensions(133, 75)
} else {
cardView.setMainImageDimensions(224, 126)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val cardView = LegacyImageCardView(parent.context, true)
cardView.isFocusable = true
cardView.isFocusableInTouchMode = true
val typedValue = TypedValue()
val theme = parent.context.theme
theme.resolveAttribute(R.attr.cardViewBackground, typedValue, true)
@ColorInt val color = typedValue.data
cardView.setBackgroundColor(color)
return ViewHolder(cardView)
}
override fun onBindViewHolder(viewHolder: Presenter.ViewHolder, item: Any) {
if (item !is BaseRowItem) return
(viewHolder as? ViewHolder)?.setItem(item)
}
override fun onUnbindViewHolder(viewHolder: Presenter.ViewHolder?) {
(viewHolder as? ViewHolder)?.setItem(null)
}
}

View File

@@ -53,7 +53,7 @@ class SearchRepositoryImpl(
val result by apiClient.itemsApi.getItems(request)
Result.success(result.items.orEmpty())
Result.success(result.items)
} catch (e: ApiClientException) {
Timber.e("Failed to search for items", e)
Result.failure(e)

View File

@@ -1,51 +0,0 @@
package org.jellyfin.androidtv.ui.shared
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.Paint
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.content.ContextCompat
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.preference.UserPreferences
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
class StrokeTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : AppCompatTextView(context, attrs, defStyleAttr), KoinComponent {
var strokeWidth = 0.0f
private var isDrawing: Boolean = false
private val subtitlesTextColor = get<UserPreferences>()[UserPreferences.subtitlesTextColor]
init {
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StrokeTextView)
strokeWidth = styledAttrs.getFloat(R.styleable.StrokeTextView_strokeWidth, 0.0f)
styledAttrs.recycle()
}
override fun invalidate() {
// To prevent infinite call of onDraw because setTextColor calls invalidate()
if (isDrawing) return
super.invalidate()
}
override fun onDraw(canvas: Canvas) {
if (strokeWidth <= 0) return super.onDraw(canvas)
isDrawing = true
paint.isAntiAlias = true
paint.strokeWidth = strokeWidth
paint.style = Paint.Style.STROKE
paint.strokeJoin = Paint.Join.ROUND
setTextColor(ContextCompat.getColor(context, R.color.black))
super.onDraw(canvas)
paint.style = Paint.Style.FILL
setTextColor(ColorStateList.valueOf(subtitlesTextColor.toInt()))
super.onDraw(canvas)
isDrawing = false
}
}

View File

@@ -27,7 +27,7 @@ import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.auth.repository.SessionRepository
import org.jellyfin.androidtv.auth.repository.SessionRepositoryState
import org.jellyfin.androidtv.auth.repository.UserRepository
import org.jellyfin.androidtv.databinding.ActivityMainBinding
import org.jellyfin.androidtv.databinding.ActivityStartupBinding
import org.jellyfin.androidtv.ui.background.AppBackground
import org.jellyfin.androidtv.ui.browsing.MainActivity
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
@@ -62,7 +62,7 @@ class StartupActivity : FragmentActivity() {
private val navigationRepository: NavigationRepository by inject()
private val itemLauncher: ItemLauncher by inject()
private lateinit var binding: ActivityMainBinding
private lateinit var binding: ActivityStartupBinding
private val networkPermissionsRequester = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
@@ -83,7 +83,7 @@ class StartupActivity : FragmentActivity() {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
binding = ActivityStartupBinding.inflate(layoutInflater)
binding.background.setContent { AppBackground() }
binding.screensaver.isVisible = false
setContentView(binding.root)
@@ -156,7 +156,7 @@ class StartupActivity : FragmentActivity() {
else -> null
}
navigationRepository.reset(destination)
navigationRepository.reset(destination, true)
val intent = Intent(this, MainActivity::class.java)
// Clear navigation history

View File

@@ -70,7 +70,7 @@ class StartupViewModel(
fun getUserImage(server: Server, user: User): String? =
authenticationRepository.getUserImageUrl(server, user)
fun loadDiscoveryservers() {
fun loadDiscoveryServers() {
// Only run one discovery process at a time
if (discoveryMutex.isLocked) return

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