Compare commits

..

44 Commits

Author SHA1 Message Date
Niels van Velzen
fe7f0c62d3 Restore forced aspect ratio for poster types on video items
(cherry picked from commit bf4198a8b8)
2024-10-13 13:57:35 +02:00
Niels van Velzen
16a382c6b5 Fix video player not closing when fragment is stopped
(cherry picked from commit c522f09cc2)
2024-10-13 13:57:29 +02:00
Niels van Velzen
bb42fd4801 Fix incorrectly navigating on resume
(cherry picked from commit 2c7bd27525)
2024-09-28 12:53:11 +02:00
Niels van Velzen
ea16d8a6a4 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>=.

(cherry picked from commit fdde857be6)
2024-09-28 12:53:11 +02:00
Bond_009
eb869d291a 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

(cherry picked from commit 90573f610f)
2024-09-28 12:53:10 +02:00
Niels van Velzen
8d2f928ad1 Fix back press callback in CustomPlaybackOverlayFragment
(cherry picked from commit 933fdbcca4)
2024-09-28 12:53:10 +02:00
renovate[bot]
1c43f27bf7 Update dependency org.jellyfin.sdk:jellyfin-core to v1.5.5
(cherry picked from commit 8d6d3b3a35)
2024-09-08 11:24:54 +02:00
Niels van Velzen
3f8c4bf662 Fix current fragment not removed on back press
(cherry picked from commit 13f6a70fa5)
2024-09-08 11:24:43 +02:00
Niels van Velzen
c3890f7ac0 Use custom logic for fragment back stack
(cherry picked from commit e460c6e403)
2024-09-08 11:24:42 +02:00
MichaelRUSF
ab165301d8 Update DeviceUtils with Xiaomi TV Stick (#3951)
* Add Mi Stick to DeviceUtils

* Fix comment

(cherry picked from commit 86d6195a06)
2024-09-08 11:24:42 +02:00
renovate[bot]
16dfd24849 Update androidx.media3 to v1.4.1
(cherry picked from commit be43311978)
2024-09-08 11:24:16 +02:00
renovate[bot]
9a189c57b7 Update dependency org.jellyfin.sdk:jellyfin-core to v1.5.4
(cherry picked from commit 3916c2a117)
2024-09-08 11:24:16 +02:00
Niels van Velzen
d7d774ffbf Fix capitalization in video profiles causing invalid transcodes
(cherry picked from commit b116db0d49)
2024-09-08 11:24:06 +02:00
MichaelRUSF
271e0cf320 Fix for devices with no HEVC Main 10 support
(cherry picked from commit fe05b82b95)
2024-09-08 11:24:06 +02:00
MichaelRUSF
8b4275dea2 AVC level check profiles
(cherry picked from commit 50ef4a7718)
2024-09-08 11:24:05 +02:00
MichaelRUSF
5b09d3b113 AVC media codec levels
(cherry picked from commit 49bcd6fe2c)
2024-09-08 11:24:05 +02:00
renovate[bot]
62f98901b0 Update Kotlin to v2.0.20
(cherry picked from commit 09027c2bc5)
2024-08-22 17:47:50 +02:00
renovate[bot]
19381684ae Update dependency androidx.tv:tv-material to v1.0.0
(cherry picked from commit f2a47d1c9a)
2024-08-22 17:47:11 +02:00
Niels van Velzen
176a7e3391 Improve behavior when no playable items found
(cherry picked from commit 1f789ac00f)
2024-08-22 17:47:07 +02:00
Niels van Velzen
2dbdbe8c28 Disable direct play when encountering playback error twice
(cherry picked from commit 1199fa5253)
2024-08-22 17:47:06 +02:00
Niels van Velzen
1502dc805b Fix toggling favorites in ItemListFragment
(cherry picked from commit a4ec607543)
2024-08-22 17:47:06 +02:00
Niels van Velzen
29fc35f215 Fix channels interpreted as Live TV channels
(cherry picked from commit ae2d74b263)
2024-08-22 17:47:06 +02:00
Niels van Velzen
af0255440c Enable constant bitrate seeking
(cherry picked from commit e65492c254)
2024-08-22 17:47:05 +02:00
Niels van Velzen
389c48a1fd Fix crash when item doesn't have a default audio/subtitle stream
(cherry picked from commit 9577e11e41)
2024-08-12 08:44:31 +02:00
Niels van Velzen
7d84011d81 Re-use supportsHevc in one additional place
(cherry picked from commit 6111cbec5a)
2024-08-11 09:59:40 +02:00
Joseph Price
36a307cd7f Really don't request HEVC if unsupported
(cherry picked from commit c7a4c89b74)
2024-08-11 09:59:39 +02:00
Niels van Velzen
2a122803c2 Fix InfoRow not using requested media source
(cherry picked from commit b0cd0c5b37)
2024-08-11 09:59:39 +02:00
Niels van Velzen
dbf6dc0f9a Reset display mode when exiting video player
(cherry picked from commit aa5fb63b71)
2024-08-11 09:59:38 +02:00
Niels van Velzen
fd663aff39 Improve InfoRow colors/font readability
(cherry picked from commit d661583a86)
2024-08-11 09:59:38 +02:00
Niels van Velzen
30e34568c1 Fix intros showing next up screen
(cherry picked from commit fe31c19f15)
2024-08-11 09:59:37 +02:00
Niels van Velzen
5fb77314c4 Fix crash when starting next episode
(cherry picked from commit fcd9751d82)
2024-08-11 09:59:37 +02:00
Niels van Velzen
d436c5de3d Graduate reactive homepage to stable
(cherry picked from commit f8c5345871)
2024-08-11 09:59:36 +02:00
renovate[bot]
8157e3e5c1 Update dependency androidx.tv:tv-material to v1.0.0-rc02
(cherry picked from commit 0385f63fbe)
2024-08-11 09:59:20 +02:00
renovate[bot]
3656c18e8f Update dependency androidx.work:work-runtime to v2.9.1
(cherry picked from commit 6cc1031b8a)
2024-08-11 09:59:19 +02:00
renovate[bot]
9f48b7da39 Update dependency org.jellyfin.sdk:jellyfin-core to v1.5.3
(cherry picked from commit 587f0fb35a)
2024-08-11 09:59:19 +02:00
Niels van Velzen
ef899f98bc Fix SelectQualityAction crashing when opening menu
(cherry picked from commit 044ce67110)
2024-08-06 15:20:34 +02:00
Niels van Velzen
de6c7904f0 Use "direct play" as default audio behavior
(cherry picked from commit f6c536880d)
2024-08-06 15:20:34 +02:00
Marc Vollmer
0a79d5b60f Fix NullPointerException in LeanbackOverlayFragment.onPause
(cherry picked from commit 3b852a031e)
2024-08-06 15:20:33 +02:00
Niels van Velzen
36e0bc1edc Fix NullPointerException in CustomPlaybackOverlayFragment.onResume
(cherry picked from commit 7bc99d2e66)
2024-08-06 15:20:33 +02:00
Niels van Velzen
8faeb3aac1 Remove context requirement from ExoPlayerProfile
(cherry picked from commit 9ecf952351)
2024-08-06 15:20:11 +02:00
majmongoose
160ef4914f 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>
(cherry picked from commit a8c9f054ab)
2024-08-06 15:20:10 +02:00
Niels van Velzen
474c0774f1 Fix AudioNowPlayingFragment showing wrong item in extremely rare cases
(cherry picked from commit 61b4178dec)
2024-08-06 15:20:10 +02:00
Niels van Velzen
0c9ba1ccec 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.

(cherry picked from commit 9aa1759ae7)
2024-08-06 15:20:10 +02:00
Niels van Velzen
0c667ab615 Strip HTML from descriptions in leanback integration
(cherry picked from commit 2e55987939)
2024-08-06 15:20:09 +02:00
235 changed files with 2700 additions and 5476 deletions

View File

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

View File

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

View File

@@ -8,18 +8,18 @@ on:
jobs:
publish:
name: Publish
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
if: ${{ contains(github.repository_owner, 'jellyfin') }}
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Setup Java
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
with:
distribution: temurin
java-version: 21
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
- 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@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0
uses: appleboy/ssh-action@029f5b4aeeeb58fdfe1410a5d17f967dacf36262 # v1.0.3
with:
host: ${{ secrets.REPO_HOST }}
username: ${{ secrets.REPO_USER }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,8 +21,4 @@
<issue id="Typos" severity="ignore" />
<issue id="UnknownNullness" severity="informational" />
<issue id="UnusedIds" severity="ignore" />
<issue id="UnusedAttribute">
<ignore regexp="usesCleartextTraffic" />
<ignore regexp="networkSecurityConfig" />
</issue>
</lint>

View File

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

View File

@@ -49,8 +49,7 @@
android:largeHeap="true"
android:supportsRtl="true"
android:theme="@style/Theme.Jellyfin"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
android:usesCleartextTraffic="true">
<!-- Legacy service required for AccountManagerMigration -->
<service

View File

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

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

View File

@@ -1,18 +1,18 @@
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.api.client.ApiClient;
import org.jellyfin.sdk.model.api.MediaProtocol;
import org.jellyfin.sdk.model.api.MediaSourceInfo;
import org.jellyfin.sdk.model.api.MediaStream;
import org.jellyfin.sdk.model.api.MediaStreamType;
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.UUID;
public class StreamInfo {
@@ -36,14 +36,14 @@ public class StreamInfo {
MediaUrl = value;
}
private PlayMethod playMethod = PlayMethod.DirectPlay;
private PlayMethod PlayMethod = getPlayMethod().values()[0];
public final PlayMethod getPlayMethod() {
return playMethod;
return PlayMethod;
}
public final void setPlayMethod(PlayMethod value) {
playMethod = value;
PlayMethod = value;
}
private EncodingContext Context = EncodingContext.values()[0];
@@ -66,6 +66,16 @@ 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() {
@@ -102,10 +112,10 @@ public class StreamInfo {
MediaSource = value;
}
public final org.jellyfin.sdk.model.api.SubtitleDeliveryMethod getSubtitleDeliveryMethod() {
Integer subtitleStreamIndex = MediaSource.getDefaultSubtitleStreamIndex();
if (subtitleStreamIndex == null || subtitleStreamIndex == -1) return SubtitleDeliveryMethod.DROP;
return MediaSource.getMediaStreams().get(subtitleStreamIndex).getDeliveryMethod();
private SubtitleDeliveryMethod SubtitleDeliveryMethod = getSubtitleDeliveryMethod().values()[0];
public final SubtitleDeliveryMethod getSubtitleDeliveryMethod() {
return SubtitleDeliveryMethod;
}
private String PlaySessionId;
@@ -122,33 +132,66 @@ public class StreamInfo {
return getMediaSource() == null ? null : getMediaSource().getId();
}
public final ArrayList<SubtitleStreamInfo> getSubtitleProfiles(ApiClient api) {
public final ArrayList<SubtitleStreamInfo> getSubtitleProfiles(boolean includeSelectedTrackOnly, String baseUrl, String accessToken) {
return getSubtitleProfiles(includeSelectedTrackOnly, false, baseUrl, accessToken);
}
public final ArrayList<SubtitleStreamInfo> getSubtitleProfiles(boolean includeSelectedTrackOnly, boolean enableAllProfiles, String baseUrl, String accessToken) {
ArrayList<SubtitleStreamInfo> list = new ArrayList<SubtitleStreamInfo>();
if (getMediaSource() == null) return list;
// HLS will preserve timestamps so we can just grab the full subtitle stream
long startPositionTicks = getPlayMethod() == PlayMethod.Transcode ? getStartPositionTicks() : 0;
for (org.jellyfin.sdk.model.api.MediaStream stream : getMediaSource().getMediaStreams()) {
if (stream.getType() == org.jellyfin.sdk.model.api.MediaStreamType.SUBTITLE) {
SubtitleStreamInfo info = getSubtitleStreamInfo(api, stream, getDeviceProfile().getSubtitleProfiles());
list.add(info);
if (!includeSelectedTrackOnly) {
if (getMediaSource() == null) return list;
for (org.jellyfin.sdk.model.api.MediaStream stream : getMediaSource().getMediaStreams()) {
if (stream.getType() == org.jellyfin.sdk.model.api.MediaStreamType.SUBTITLE) {
addSubtitleProfiles(list, stream, enableAllProfiles, baseUrl, accessToken, startPositionTicks);
}
}
}
return list;
}
private SubtitleStreamInfo getSubtitleStreamInfo(ApiClient api, org.jellyfin.sdk.model.api.MediaStream stream, SubtitleProfile[] subtitleProfiles) {
SubtitleProfile subtitleProfile = StreamBuilder.getSubtitleProfile(stream, subtitleProfiles, getPlayMethod());
SubtitleStreamInfo info = new SubtitleStreamInfo();
String tempVar2 = stream.getLanguage();
info.setName((tempVar2 != null) ? tempVar2 : "Unknown");
info.setFormat(subtitleProfile.getFormat());
info.setIndex(stream.getIndex());
info.setDeliveryMethod(subtitleProfile.getMethod());
info.setDisplayTitle(stream.getDisplayTitle());
if (stream.getDeliveryUrl() != null) {
info.setUrl(api.createUrl(stream.getDeliveryUrl(), new HashMap<>(), new HashMap<>(), true));
private void addSubtitleProfiles(ArrayList<SubtitleStreamInfo> list, org.jellyfin.sdk.model.api.MediaStream stream, boolean enableAllProfiles, String baseUrl, String accessToken, long startPositionTicks) {
if (enableAllProfiles) {
for (SubtitleProfile profile : getDeviceProfile().getSubtitleProfiles()) {
SubtitleStreamInfo info = getSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, new SubtitleProfile[]{profile});
list.add(info);
}
} else {
SubtitleStreamInfo info = getSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, getDeviceProfile().getSubtitleProfiles());
list.add(info);
}
}
private SubtitleStreamInfo getSubtitleStreamInfo(org.jellyfin.sdk.model.api.MediaStream stream, String baseUrl, String accessToken, long startPositionTicks, SubtitleProfile[] subtitleProfiles) {
SubtitleProfile subtitleProfile = StreamBuilder.getSubtitleProfile(stream, subtitleProfiles, getPlayMethod());
SubtitleStreamInfo tempVar = new SubtitleStreamInfo();
String tempVar2 = stream.getLanguage();
tempVar.setName((tempVar2 != null) ? tempVar2 : "Unknown");
tempVar.setFormat(subtitleProfile.getFormat());
tempVar.setIndex(stream.getIndex());
tempVar.setDeliveryMethod(subtitleProfile.getMethod());
tempVar.setDisplayTitle(stream.getDisplayTitle());
SubtitleStreamInfo info = tempVar;
if (info.getDeliveryMethod() == SubtitleDeliveryMethod.External) {
if (getMediaSource().getProtocol() == MediaProtocol.FILE || !stream.getCodec().equalsIgnoreCase(subtitleProfile.getFormat())) {
info.setUrl(String.format("%1$s/Videos/%2$s/%3$s/Subtitles/%4$s/%5$s/Stream.%6$s", baseUrl, getItemId(), getMediaSourceId(), String.valueOf(stream.getIndex()), String.valueOf(startPositionTicks), subtitleProfile.getFormat()));
if (!Utils.isEmpty(accessToken)) {
info.setUrl(info.getUrl() + "?api_key=" + accessToken);
}
} else {
info.setUrl(stream.getPath());
}
}
return info;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -221,6 +221,7 @@ class LeanbackChannelWorker(
// Add new items
return response.items
.orEmpty()
.filter { userViewsRepository.isSupported(it.collectionType) }
}
@@ -276,7 +277,7 @@ class LeanbackChannelWorker(
mediaTypes = listOf(MediaType.VIDEO),
includeItemTypes = listOf(BaseItemKind.EPISODE, BaseItemKind.MOVIE),
excludeActiveSessions = true,
).content.items
).content.items.orEmpty()
}
val nextUp = async {
@@ -285,7 +286,7 @@ class LeanbackChannelWorker(
limit = 10,
enableResumable = false,
fields = listOf(ItemFields.DATE_CREATED),
).content.items
).content.items.orEmpty()
}
// Concat

View File

@@ -2,17 +2,18 @@ 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 coil3.ImageLoader
import coil3.request.ImageRequest
import coil3.toBitmap
import coil.ImageLoader
import coil.request.ImageRequest
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
@@ -21,13 +22,14 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import org.jellyfin.androidtv.integration.dream.model.DreamContent
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.playback.core.PlaybackManager
import org.jellyfin.playback.core.queue.queue
import org.jellyfin.playback.jellyfin.queue.baseItem
import org.jellyfin.androidtv.ui.playback.AudioEventListener
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.androidtv.ui.playback.PlaybackController
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
@@ -40,17 +42,35 @@ class DreamViewModel(
private val api: ApiClient,
private val imageLoader: ImageLoader,
private val context: Context,
playbackManager: PlaybackManager,
private val mediaManager: MediaManager,
private val userPreferences: UserPreferences,
) : ViewModel() {
@OptIn(ExperimentalCoroutinesApi::class)
private val _mediaContent = playbackManager.queue.entry
.map { entry ->
entry?.baseItem?.let { baseItem ->
DreamContent.NowPlaying(entry, baseItem)
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)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
mediaManager.addAudioEventListener(listener)
awaitClose { mediaManager.removeAudioEventListener(listener) }
}
.distinctUntilChanged()
.map { it?.let(DreamContent::NowPlaying) }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(),
DreamContent.NowPlaying(mediaManager.currentAudioItem)
)
private val _libraryContent = flow {
// Load first library item after 2 seconds
@@ -94,7 +114,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
@@ -123,13 +143,13 @@ class DreamViewModel(
val logoDeferred = async {
imageLoader.execute(
request = ImageRequest.Builder(context).data(logoUrl).build()
).image?.toBitmap()
).drawable?.toBitmap()
}
val backdropDeferred = async {
imageLoader.execute(
request = ImageRequest.Builder(context).data(backdropUrl).build()
).image?.toBitmap()
).drawable?.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.modifier.overscan
import org.jellyfin.androidtv.ui.composable.overscan
@Composable
fun DreamContentLibraryShowcase(

View File

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

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.modifier.overscan
import org.jellyfin.androidtv.ui.composable.overscan
import org.jellyfin.androidtv.ui.composable.rememberCurrentTime
@Composable

View File

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

View File

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

View File

@@ -10,17 +10,12 @@ 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
/**
@@ -50,7 +45,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 permissions.
* Enable management of media like deleting items when the user has sufficient permisisons.
*/
var mediaManagementEnabled = booleanPreference("enable_media_management", false)
@@ -97,11 +92,6 @@ 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.
@@ -166,25 +156,30 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
var seriesThumbnailsEnabled = booleanPreference("pref_enable_series_thumbnails", true)
/**
* Subtitles foreground color
* Enable subtitles background
*/
var subtitlesBackgroundColor = longPreference("subtitles_background_color", 0x00FFFFFF)
var subtitlesBackgroundEnabled = booleanPreference("subtitles_background_enabled", true)
/**
* Subtitles font size
*/
var subtitlesSize = intPreference("subtitles_size", 28)
/**
* Subtitles stroke size
*/
var subtitleStrokeSize = intPreference("subtitles_stroke_size", 0)
/**
* Subtitles position
*/
var subtitlePosition = intPreference("subtitles_position", 40)
/**
* Subtitles foreground color
*/
var subtitlesTextColor = longPreference("subtitles_text_color", 0xFFFFFFFF)
/**
* Subtitles stroke color
*/
var subtitleTextStrokeColor = longPreference("subtitles_text_stroke_color", 0xFF000000)
/**
* Subtitles font size
*/
var subtitlesTextSize = floatPreference("subtitles_text_size", 1f)
/**
* Show screensaver in app
*/
@@ -204,32 +199,6 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
* Whether items shown in the screensaver are required to have an age rating set.
*/
var screensaverAgeRatingRequired = booleanPreference("screensaver_agerating_required", true)
/**
* Delay when starting video playback after loading the video player.
*/
var videoStartDelay = longPreference("video_start_delay", 0)
/**
* The actions to take for each media segment type. Managed by the [MediaSegmentRepository].
*/
var mediaSegmentActions = stringPreference(
key = "media_segment_actions",
defaultValue = mapOf(
MediaSegmentType.INTRO to MediaSegmentAction.ASK_TO_SKIP,
MediaSegmentType.OUTRO to MediaSegmentAction.ASK_TO_SKIP,
).toMediaSegmentActionsString()
)
/**
* Preferred behavior for player aspect ratio (zoom mode).
*/
var playerZoomMode = enumPreference("player_zoom_mode", ZoomMode.FIT)
/**
* Enable TrickPlay in legacy player user interface while seeking.
*/
var trickPlayEnabled = booleanPreference("trick_play_enabled", false)
}
init {
@@ -242,17 +211,6 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
// Enable playback rewrite for music
putBoolean("playback_new_audio", true)
}
// v0.17.z to v0.18.0
migration(toVersion = 8) {
// Set subtitle background color to black if it was enabled in a previous version
val subtitlesBackgroundEnabled = it.getBoolean("subtitles_background_enabled", true)
putLong("subtitles_background_color", if (subtitlesBackgroundEnabled) 0XFF000000L else 0X00FFFFFFL)
// Set subtitle text stroke color to black if it was enabled in a previous version
val subtitleStrokeSize = it.getInt("subtitles_stroke_size", 0)
putLong("subtitles_text_stroke_color", if (subtitleStrokeSize > 0) 0XFF000000L else 0X00FFFFFFL)
}
}
}
}

View File

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

View File

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

View File

@@ -82,9 +82,6 @@ abstract class DisplayPreferencesStore(
override fun getLong(key: String, defaultValue: Long) =
cachedPreferences[key]?.toLongOrNull() ?: defaultValue
override fun getFloat(key: String, defaultValue: Float) =
cachedPreferences[key]?.toFloatOrNull() ?: defaultValue
override fun getBool(key: String, defaultValue: Boolean) =
cachedPreferences[key]?.toBooleanStrictOrNull() ?: defaultValue
@@ -99,10 +96,6 @@ abstract class DisplayPreferencesStore(
cachedPreferences[key] = value.toString()
}
override fun setFloat(key: String, value: Float) {
cachedPreferences[key] = value.toString()
}
override fun setBool(key: String, value: Boolean) {
cachedPreferences[key] = value.toString()
}

View File

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

View File

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

View File

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

View File

@@ -29,6 +29,7 @@ import androidx.leanback.widget.VerticalGridPresenter;
import androidx.lifecycle.Lifecycle;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.auth.repository.UserRepository;
import org.jellyfin.androidtv.constant.ChangeTriggerType;
import org.jellyfin.androidtv.constant.CustomMessage;
import org.jellyfin.androidtv.constant.Extras;
@@ -116,6 +117,7 @@ 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);
@@ -167,12 +169,7 @@ 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));
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));
}
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));
@@ -188,7 +185,12 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
binding = HorizontalGridBrowseBinding.inflate(inflater, container, false);
// Hide the description because we don't have room for it
binding.npBug.showDescription(false);
return binding.getRoot();
}
@@ -471,11 +473,11 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
if (numRows > 0) {
double paddingPct = cardScaling / numRows;
double spacingPct = ((paddingPct / 2.0) * CARD_SPACING_PCT) * (numRows - 1);
double spaceingPct = ((paddingPct / 2.0) * CARD_SPACING_PCT) * (numRows - 1);
double wastedSpacePct = paddingPct + spacingPct;
double usableCardSpace = mGridHeight / (1.0 + wastedSpacePct); // decrease size
double cardHeight = usableCardSpace / numRows;
double wastedSpacePct = paddingPct + spaceingPct;
double useableCardSpace = mGridHeight / (1.0 + wastedSpacePct); // decrease size
double cardHeight = useableCardSpace / numRows;
// fix any rounding errors and make pixel perfect
cardHeightInt = (int) Math.round(cardHeight);
@@ -499,14 +501,14 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
mCardsScreenStride = numRows;
} else if (numCols > 0) {
double paddingPct = cardScaling / numCols;
double spacingPct = ((paddingPct / 2.0) * CARD_SPACING_PCT) * (numCols - 1);
double spaceingPct = ((paddingPct / 2.0) * CARD_SPACING_PCT) * (numCols - 1);
if (mImageType == ImageType.BANNER) {
spacingPct = ((paddingPct / 2.0) * CARD_SPACING_HORIZONTAL_BANNER_PCT) * (numCols - 1);
spaceingPct = ((paddingPct / 2.0) * CARD_SPACING_HORIZONTAL_BANNER_PCT) * (numCols - 1);
}
double wastedSpacePct = paddingPct + spacingPct;
double usableCardSpace = mGridWidth / (1.0 + wastedSpacePct); // decrease size
double cardWidth = usableCardSpace / numCols;
double wastedSpacePct = paddingPct + spaceingPct;
double useableCardSpace = mGridWidth / (1.0 + wastedSpacePct); // decrease size
double cardWidth = useableCardSpace / numCols;
// fix any rounding errors and make pixel perfect
cardHeightInt = (int) Math.round(getCardHeightBy(cardWidth, mImageType, mFolder));
@@ -619,7 +621,6 @@ 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,6 +4,7 @@ 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;
@@ -137,6 +138,12 @@ 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,7 +345,6 @@ object BrowsingUtils {
),
includeItemTypes = setOf(BaseItemKind.EPISODE),
parentId = parentId,
indexNumber = 1,
recursive = true,
isMissing = false,
imageTypeLimit = 1,
@@ -415,6 +414,12 @@ object BrowsingUtils {
recursive = true,
)
CollectionType.BOXSETS -> baseRequest.copy(
includeItemTypes = setOf(BaseItemKind.BOX_SET),
parentId = null,
recursive = true,
)
CollectionType.MUSIC -> baseRequest.copy(
includeItemTypes = setOf(BaseItemKind.MUSIC_ALBUM),
recursive = true,

View File

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

View File

@@ -166,7 +166,7 @@ class DestinationFragmentView @JvmOverloads constructor(
// Save state
return bundleOf(
BUNDLE_SUPER to super.onSaveInstanceState(),
BUNDLE_HISTORY to ArrayList(history),
BUNDLE_HISTORY to history.toTypedArray()
)
}
@@ -177,10 +177,11 @@ class DestinationFragmentView @JvmOverloads constructor(
// Call parent
@Suppress("DEPRECATION")
val parent = state.getParcelable<Parcelable>(BUNDLE_SUPER)
super.onRestoreInstanceState(parent)
if (parent != null) super.onRestoreInstanceState(parent)
// Restore history
val savedHistory = BundleCompat.getParcelableArrayList(state, BUNDLE_HISTORY, HistoryEntry::class.java)
@Suppress("UNCHECKED_CAST")
val savedHistory = BundleCompat.getParcelableArray(state, BUNDLE_HISTORY, HistoryEntry::class.java) as Array<HistoryEntry>?
if (savedHistory != null) {
history.clear()
history.addAll(savedHistory)

View File

@@ -111,8 +111,6 @@ class MainActivity : FragmentActivity() {
}
private fun handleNavigationAction(action: NavigationAction) {
screensaverViewModel.notifyInteraction(true)
when (action) {
// DestinationFragmentView actions
is NavigationAction.NavigateFragment -> binding.contentView.navigate(action)

View File

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

View File

@@ -42,14 +42,14 @@ public class ChannelCardView extends FrameLayout {
.append(DateTimeExtensionsKt.getTimeFormatter(getContext()).format(program.getEndDate()))
);
if (program.getStartDate().isBefore(LocalDateTime.now()) && program.getEndDate().isAfter(LocalDateTime.now())) {
if (program.getStartDate().isAfter(LocalDateTime.now()) && program.getEndDate().isBefore(LocalDateTime.now())) {
Duration duration = Duration.between(program.getStartDate(), program.getEndDate());
Duration progress = Duration.between(program.getStartDate(), LocalDateTime.now());
binding.progress.setProgress((int) ((progress.getSeconds() / (double) duration.getSeconds()) * 100));
binding.progress.setProgress((int) (progress.getSeconds() / duration.getSeconds() * 100));
} else {
binding.progress.setProgress(0);
}
}
} else {
binding.time.setText("");
binding.progress.setProgress(0);

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

@@ -1,214 +0,0 @@
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

@@ -0,0 +1,37 @@
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

@@ -1,115 +0,0 @@
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

@@ -1,16 +0,0 @@
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

@@ -0,0 +1,10 @@
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

@@ -1,67 +0,0 @@
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,8 +4,9 @@ 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
@@ -18,9 +19,14 @@ 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(userViews: Collection<BaseItemDto>): HomeFragmentRow {
return HomeFragmentLatestRow(userRepository, userViews)
fun loadRecentlyAdded(): HomeFragmentRow {
return HomeFragmentLatestRow(userRepository, userViewsRepository)
}
fun loadLibraryTiles(): HomeFragmentRow {
return HomeFragmentBrowseRowDefRow(BrowseRowDef(context.getString(R.string.lbl_my_media), GetUserViewsRequest))
}
fun loadResume(title: String, includeMediaTypes: Collection<MediaType>): HomeFragmentRow {

View File

@@ -2,20 +2,22 @@ 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 userViews: Collection<BaseItemDto>,
private val userViewsRepository: UserViewsRepository,
) : HomeFragmentRow {
override fun addToRowsAdapter(context: Context, cardPresenter: CardPresenter, rowsAdapter: MutableObjectAdapter<Row>) {
// Get configuration (to find excluded items)
@@ -23,7 +25,8 @@ class HomeFragmentLatestRow(
// Create a list of views to include
val latestItemsExcludes = configuration?.latestItemsExcludes.orEmpty()
userViews
val views = runBlocking { userViewsRepository.views.first() }
views
.filterNot { item -> item.collectionType in EXCLUDED_COLLECTION_TYPES || item.id in latestItemsExcludes }
.map { item ->
// Create query and add it to a new row
@@ -49,7 +52,7 @@ class HomeFragmentLatestRow(
}
companion object {
// Collections excluded from latest row based on app support and common sense
// Collections exclused from latest row based on app support and common sense
private val EXCLUDED_COLLECTION_TYPES = arrayOf(
CollectionType.PLAYLISTS,
CollectionType.LIVETV,
@@ -57,7 +60,7 @@ class HomeFragmentLatestRow(
CollectionType.BOOKS,
)
// Maximum amount of items loaded for a row
// Maximum ammount of items loaded for a row
private const val ITEM_LIMIT = 50
}
}

View File

@@ -1,32 +0,0 @@
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,6 +32,7 @@ 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
@@ -61,6 +62,7 @@ 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>()
@@ -69,7 +71,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) }
private val helper by lazy { HomeFragmentHelper(requireContext(), userRepository, userViewsRepository) }
// Data
private var currentItem: BaseRowItem? = null
@@ -92,7 +94,7 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
}
// Start out with default sections
val homesections = userSettingPreferences.activeHomesections
val homesections = userSettingPreferences.homesections
var includeLiveTvRows = false
// Check for live TV support
@@ -105,7 +107,7 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
isAiring = true,
limit = 1,
)
includeLiveTvRows = recommendedPrograms.items.isNotEmpty()
includeLiveTvRows = !recommendedPrograms.items.isNullOrEmpty()
}
// Make sure the rows are empty
@@ -116,9 +118,9 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
// Actually add the sections
for (section in homesections) when (section) {
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.LATEST_MEDIA -> rows.add(helper.loadRecentlyAdded())
HomeSectionType.LIBRARY_TILES_SMALL -> rows.add(helper.loadLibraryTiles())
HomeSectionType.LIBRARY_BUTTONS -> rows.add(helper.loadLibraryTiles())
HomeSectionType.RESUME -> rows.add(helper.loadResumeVideo())
HomeSectionType.RESUME_AUDIO -> rows.add(helper.loadResumeAudio())
HomeSectionType.RESUME_BOOK -> Unit // Books are not (yet) supported

View File

@@ -709,7 +709,7 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
}
});
} else {
mediaManager.getValue().queueAudioItem(baseItem);
mediaManager.getValue().addToAudioQueue(Arrays.asList(baseItem));
}
}
}

View File

@@ -28,7 +28,6 @@ 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
@@ -242,12 +241,6 @@ 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

@@ -51,7 +51,7 @@ fun MusicFavoritesListFragment.getFavoritePlaylist(
fields = itemFields,
)
callback(result.items)
callback(result.items.orEmpty())
}
}
@@ -79,7 +79,7 @@ fun ItemListFragment.getPlaylist(
)
}
callback(result.items)
callback(result.items.orEmpty())
}
}

View File

@@ -79,8 +79,6 @@ 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,
items = response.items.orEmpty(),
transform = { item, _ ->
BaseItemDtoBaseRowItem(
item,
@@ -87,7 +87,7 @@ fun ItemRowAdapter.retrieveResumeItems(api: ApiClient, query: GetResumeItemsRequ
}
)
if (response.items.isEmpty()) removeRow()
if (response.items.isNullOrEmpty()) 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)
addAll(episodesResponse.items)
episodesResponse.items?.let { addAll(it) }
}
setItems(
@@ -131,7 +131,7 @@ fun ItemRowAdapter.retrieveNextUpItems(api: ApiClient, query: GetNextUpRequest)
if (items.isEmpty()) removeRow()
} else {
setItems(
items = response.items,
items = response.items.orEmpty(),
transform = { item, _ ->
BaseItemDtoBaseRowItem(
item,
@@ -141,7 +141,7 @@ fun ItemRowAdapter.retrieveNextUpItems(api: ApiClient, query: GetNextUpRequest)
}
)
if (response.items.isEmpty()) removeRow()
if (response.items.isNullOrEmpty()) 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,
items = response.items.orEmpty(),
transform = { item, _ -> BaseItemDtoBaseRowItem(item) }
)
if (response.items.isEmpty()) removeRow()
if (response.items.isNullOrEmpty()) 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
val filteredItems = response.items.orEmpty()
.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,
items = response.items.orEmpty(),
transform = { item, _ -> BaseItemDtoBaseRowItem(item) }
)
if (response.items.isEmpty()) removeRow()
if (response.items.isNullOrEmpty()) 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,
items = response.items.orEmpty(),
transform = { item, _ -> BaseItemDtoBaseRowItem(item) }
)
if (response.items.isEmpty()) removeRow()
if (response.items.isNullOrEmpty()) 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,
items = response.items.orEmpty(),
transform = { item, _ -> BaseItemDtoBaseRowItem(item) }
)
if (response.items.isEmpty()) removeRow()
if (response.items.isNullOrEmpty()) 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,
items = response.items.orEmpty(),
transform = { item, _ ->
BaseItemDtoBaseRowItem(
item,
@@ -335,7 +335,7 @@ fun ItemRowAdapter.retrieveLiveTvRecommendedPrograms(
}
)
if (response.items.isEmpty()) removeRow()
if (response.items.isNullOrEmpty()) 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,
items = response.items.orEmpty(),
transform = { item, _ ->
BaseItemDtoBaseRowItem(
item,
@@ -359,7 +359,7 @@ fun ItemRowAdapter.retrieveLiveTvRecordings(api: ApiClient, query: GetRecordings
}
)
if (response.items.isEmpty()) removeRow()
if (response.items.isNullOrEmpty()) removeRow()
}.fold(
onSuccess = { notifyRetrieveFinished() },
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
@@ -401,7 +401,7 @@ fun ItemRowAdapter.retrieveLiveTvSeriesTimers(
)
}
addAll(response.items)
addAll(response.items.orEmpty())
},
transform = { item, _ ->
when (item) {
@@ -412,7 +412,7 @@ fun ItemRowAdapter.retrieveLiveTvSeriesTimers(
}
)
if (response.items.isEmpty()) removeRow()
if (response.items.isNullOrEmpty()) removeRow()
}.fold(
onSuccess = { notifyRetrieveFinished() },
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
@@ -437,7 +437,7 @@ fun ItemRowAdapter.retrieveLiveTvChannels(
totalItems = response.totalRecordCount
setItems(
items = response.items,
items = response.items.orEmpty(),
transform = { item, _ ->
BaseItemDtoBaseRowItem(
item,
@@ -447,7 +447,7 @@ fun ItemRowAdapter.retrieveLiveTvChannels(
},
)
if (response.items.isEmpty()) removeRow()
if (response.items.isNullOrEmpty()) removeRow()
}.fold(
onSuccess = { notifyRetrieveFinished() },
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
@@ -472,7 +472,7 @@ fun ItemRowAdapter.retrieveAlbumArtists(
totalItems = response.totalRecordCount
setItems(
items = response.items,
items = response.items.orEmpty(),
transform = { item, _ ->
BaseItemDtoBaseRowItem(
item,
@@ -482,7 +482,7 @@ fun ItemRowAdapter.retrieveAlbumArtists(
},
)
if (response.items.isEmpty()) removeRow()
if (response.items.isNullOrEmpty()) removeRow()
}.fold(
onSuccess = { notifyRetrieveFinished() },
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
@@ -507,7 +507,7 @@ fun ItemRowAdapter.retrieveArtists(
totalItems = response.totalRecordCount
setItems(
items = response.items,
items = response.items.orEmpty(),
transform = { item, _ ->
BaseItemDtoBaseRowItem(
item,
@@ -517,7 +517,7 @@ fun ItemRowAdapter.retrieveArtists(
},
)
if (response.items.isEmpty()) removeRow()
if (response.items.isNullOrEmpty()) removeRow()
}.fold(
onSuccess = { notifyRetrieveFinished() },
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
@@ -542,7 +542,7 @@ fun ItemRowAdapter.retrieveItems(
totalItems = response.totalRecordCount
setItems(
items = response.items,
items = response.items.orEmpty(),
transform = { item, _ ->
BaseItemDtoBaseRowItem(
item,
@@ -552,7 +552,7 @@ fun ItemRowAdapter.retrieveItems(
},
)
if (response.items.isEmpty()) removeRow()
if (response.items.isNullOrEmpty()) removeRow()
}.fold(
onSuccess = { notifyRetrieveFinished() },
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
@@ -567,9 +567,12 @@ 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 = response.items,
items = filteredItems,
transform = { item, _ ->
BaseItemDtoBaseRowItem(
item,
@@ -579,7 +582,7 @@ fun ItemRowAdapter.retrievePremieres(
}
)
if (response.items.isEmpty()) removeRow()
if (filteredItems.isEmpty()) removeRow()
}.fold(
onSuccess = { notifyRetrieveFinished() },
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }
@@ -694,8 +697,6 @@ fun ItemRowAdapter.refreshItem(
}
)
withContext(Dispatchers.Main) {
callback()
}
callback()
}
}

View File

@@ -235,9 +235,7 @@ public class LiveTvGuideFragment extends Fragment implements LiveTvGuide, View.O
public void refreshFavorite(UUID channelId){
for (int i = 0; i < mChannels.getChildCount(); i++) {
View child = mChannels.getChildAt(i);
if (!(child instanceof GuideChannelHeader)) continue;
GuideChannelHeader gch = (GuideChannelHeader) child;
GuideChannelHeader gch = (GuideChannelHeader)mChannels.getChildAt(i);
if (gch.getChannel().getId().equals(channelId.toString()))
gch.refreshFavorite();
}
@@ -540,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.setFocusParams(row, prevRow, true);
TvManager.setFocusParams(prevRow, row, false);
TvManager.setFocusParms(row, prevRow, true);
TvManager.setFocusParms(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 setFocusParams(LinearLayout currentRow, LinearLayout otherRow, boolean up) {
public static void setFocusParms(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
).content.items.orEmpty()
}.fold(
onSuccess = { channels -> callback(channels) },
onFailure = { callback(null) },
@@ -84,7 +84,7 @@ fun getScheduleRows(
runCatching {
api.liveTvApi.getTimers(
seriesTimerId = seriesTimerId,
).content.items
).content.items.orEmpty()
}.fold(
onSuccess = { timers ->
val groupedTimers = timers

View File

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

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 presentation
// Only auto-hide when there is an active presenation
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
album = albumResponse.items.orEmpty()
albumIndex = album.indexOfFirst { it.id == id }
// In some rare cases the album of the image might be empty when the

View File

@@ -42,7 +42,6 @@ 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;
@@ -85,7 +84,6 @@ 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);
@@ -109,7 +107,6 @@ 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));
@@ -255,10 +252,15 @@ public class AudioNowPlayingFragment extends Fragment {
@Override
public void onQueueStatusChanged(boolean hasQueue) {
Timber.d("Queue status changed (hasQueue=%s)", hasQueue);
loadItem();
if (mediaManager.getValue().isAudioPlayerInitialized()) {
updateButtons();
Timber.d("Queue status changed");
if (hasQueue) {
loadItem();
if (mediaManager.getValue().isAudioPlayerInitialized()) {
updateButtons();
}
} else {
if (navigationRepository.getValue().getCanGoBack()) navigationRepository.getValue().goBack();
else navigationRepository.getValue().reset(Destinations.INSTANCE.getHome());
}
}
@@ -303,9 +305,6 @@ public class AudioNowPlayingFragment extends Fragment {
if (mBaseItem != null) {
updatePoster();
updateInfo(mBaseItem);
} else {
if (navigationRepository.getValue().getCanGoBack()) navigationRepository.getValue().goBack();
else navigationRepository.getValue().navigate(Destinations.INSTANCE.getHome());
}
}

View File

@@ -1,63 +0,0 @@
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

@@ -10,6 +10,9 @@ 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;
@@ -24,6 +27,7 @@ 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;
@@ -43,6 +47,7 @@ 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;
@@ -64,6 +69,7 @@ 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;
@@ -73,9 +79,12 @@ 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;
@@ -86,7 +95,7 @@ import kotlin.Lazy;
import timber.log.Timber;
public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGuide, View.OnKeyListener {
protected VlcPlayerInterfaceBinding binding;
private VlcPlayerInterfaceBinding binding;
private OverlayTvGuideBinding tvGuideBinding;
private RowsSupportFragment mPopupRowsFragment;
@@ -123,9 +132,20 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
private boolean mFadeEnabled = false;
private boolean mIsVisible = false;
private boolean mPopupPanelVisible = false;
private boolean navigating = false;
protected LeanbackOverlayFragment leanbackOverlayFragment;
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());
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);
@@ -155,7 +175,11 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
requireActivity().setVolumeControlStream(AudioManager.STREAM_MUSIC);
mItemsToPlay = videoQueueManager.getValue().getCurrentVideoQueue();
if (mItemsToPlay == null || mItemsToPlay.isEmpty()) return;
if (mItemsToPlay == null || mItemsToPlay.size() == 0) {
Utils.showToast(requireContext(), getString(R.string.msg_no_playable_items));
closePlayer();
return;
}
int mediaPosition = videoQueueManager.getValue().getCurrentMediaPosition();
@@ -164,6 +188,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
// setup fade task
mHideTask = () -> {
if (mIsVisible) {
hide();
leanbackOverlayFragment.hideOverlay();
}
};
@@ -198,6 +223,9 @@ 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();
@@ -212,12 +240,6 @@ 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);
}
@@ -235,10 +257,30 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
if (mItemsToPlay == null || mItemsToPlay.isEmpty()) return;
if (mItemsToPlay == null || mItemsToPlay.size() == 0) 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);
@@ -297,8 +339,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
playbackControllerContainer.getValue().getPlaybackController().play(startPos);
leanbackOverlayFragment.updatePlayState();
// Set initial skip overlay state
binding.skipOverlay.setSkipUiEnabled(!mIsVisible && !mGuideVisible && !mPopupPanelVisible);
}
@Override
@@ -476,22 +516,6 @@ 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;
@@ -600,6 +624,9 @@ 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
@@ -653,10 +680,14 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
public void onResume() {
super.onResume();
// Close player when resuming without a valid playback controller
// Close player when resuming without a valid playback contoller
PlaybackController playbackController = playbackControllerContainer.getValue().getPlaybackController();
if (playbackController == null || !playbackController.hasFragment()) {
closePlayer();
if (navigationRepository.getValue().getCanGoBack()) {
navigationRepository.getValue().goBack();
} else {
navigationRepository.getValue().reset(Destinations.INSTANCE.getHome());
}
return;
}
@@ -675,7 +706,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
@Override
public void onPause() {
super.onPause();
if (mItemsToPlay == null || mItemsToPlay.isEmpty()) return;
setPlayPauseActionState(0);
@@ -702,34 +732,24 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
}
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() {
@@ -750,14 +770,12 @@ 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() {
@@ -807,7 +825,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
mDisplayProgramsTask.execute(mCurrentDisplayChannelStartNdx, mCurrentDisplayChannelEndNdx);
}
});
binding.skipOverlay.setSkipUiEnabled(!mIsVisible && !mGuideVisible && !mPopupPanelVisible);
}
DisplayProgramsTask mDisplayProgramsTask;
@@ -872,8 +889,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.setFocusParams(row, prevRow, true);
TvManager.setFocusParams(prevRow, row, false);
TvManager.setFocusParms(row, prevRow, true);
TvManager.setFocusParms(prevRow, row, false);
}
prevRow = row;
@@ -1205,7 +1222,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
}
public void setCurrentTime(long time) {
binding.skipOverlay.setCurrentPositionMs(time);
if (leanbackOverlayFragment != null)
leanbackOverlayFragment.updateCurrentPosition();
}
@@ -1215,6 +1231,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
public void setFadingEnabled(boolean value) {
mFadeEnabled = value;
if (!mIsVisible) requireActivity().runOnUiThread(this::show);
if (mFadeEnabled) {
startFadeTimer();
} else {
@@ -1291,9 +1308,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
}
public void closePlayer() {
if (navigating) return;
navigating = true;
if (navigationRepository.getValue().getCanGoBack()) {
navigationRepository.getValue().goBack();
} else {
@@ -1302,12 +1316,140 @@ 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();

View File

@@ -19,7 +19,6 @@ 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
@@ -175,7 +174,3 @@ 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 = (int) data.getLongExtra(API_VLC_RESULT_POSITION, 0);
pos = data.getIntExtra(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(this, item, mCurrentStreamInfo, reportPos);
reportingHelper.getValue().reportStopped(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(this, playbackController, mItemsToPlay.get(mCurrentNdx), mCurrentStreamInfo, 0, false);
reportingHelper.getValue().reportProgress(playbackController, mItemsToPlay.get(mCurrentNdx), mCurrentStreamInfo, null, false);
mReportLoop = new Runnable() {
@Override
public void run() {
if (!getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) return;
reportingHelper.getValue().reportProgress(ExternalPlayerActivity.this, playbackController, mItemsToPlay.get(mCurrentNdx), mCurrentStreamInfo, mPosition * RUNTIME_TICKS_TO_MS, false);
reportingHelper.getValue().reportProgress(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(this, playbackControllerContainer.getValue().getPlaybackController(), item, mCurrentStreamInfo, mPosition * RUNTIME_TICKS_TO_MS, false);
reportingHelper.getValue().reportStart(item, mPosition * RUNTIME_TICKS_TO_MS);
startReportLoop();
startActivityForResult(external, 1);
} catch (ActivityNotFoundException e) {
@@ -392,7 +392,9 @@ public class ExternalPlayerActivity extends FragmentActivity {
* @param playerIntent Put player API params of sub urls.
*/
private void adaptExternalSubtitles(StreamInfo mediaStreamInfo, Intent playerIntent) {
List<SubtitleStreamInfo> externalSubs = mediaStreamInfo.getSubtitleProfiles(apiClient.getValue()).stream()
List<SubtitleStreamInfo> externalSubs = mediaStreamInfo.getSubtitleProfiles(false,
apiClient.getValue().getBaseUrl(), apiClient.getValue().getAccessToken()).stream()
.filter(stream -> stream.getDeliveryMethod() == SubtitleDeliveryMethod.External && stream.getUrl() != null)
.collect(Collectors.toList());

View File

@@ -8,6 +8,7 @@ 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;
@@ -25,8 +26,9 @@ 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) {
public GetPlaybackInfoResponse(PlaybackManager playbackManager, DeviceInfo deviceInfo, ApiClient apiClient, AudioOptions options, Response<StreamInfo> response, boolean isVideo, Long startPositionTicks) {
super(response);
this.playbackManager = playbackManager;
this.deviceInfo = deviceInfo;
@@ -34,6 +36,7 @@ public class GetPlaybackInfoResponse extends Response<PlaybackInfoResponse> {
this.options = options;
this.response = response;
this.isVideo = isVideo;
this.startPositionTicks = startPositionTicks;
}
@Override
@@ -93,6 +96,7 @@ 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)) {
@@ -165,6 +169,14 @@ 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,6 +15,7 @@ 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;
@@ -22,16 +23,20 @@ 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.DeviceUtils;
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;
@@ -39,7 +44,6 @@ 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;
@@ -51,8 +55,6 @@ import java.util.List;
import kotlin.Lazy;
import timber.log.Timber;
import java.time.Duration;
public class PlaybackController implements PlaybackControllerNotifiable {
// Frequency to report playback progress
private final static long PROGRESS_REPORTING_INTERVAL = TimeUtils.secondsToMillis(3);
@@ -70,18 +72,20 @@ public class PlaybackController implements PlaybackControllerNotifiable {
List<BaseItemDto> mItems;
VideoManager mVideoManager;
int mCurrentIndex;
protected long mCurrentPosition = 0;
private long mCurrentPosition = 0;
private PlaybackState mPlaybackState = PlaybackState.IDLE;
private StreamInfo mCurrentStreamInfo;
private List<SubtitleStreamInfo> mSubtitleStreams;
@Nullable
private CustomPlaybackOverlayFragment mFragment;
private Boolean spinnerOff = false;
protected VideoOptions mCurrentOptions;
private VideoOptions mCurrentOptions;
private int mDefaultSubIndex = -1;
private int mDefaultAudioIndex = -1;
protected boolean burningSubs = false;
private boolean burningSubs = false;
private float mRequestedPlaybackSpeed = -1.0f;
private Runnable mReportLoop;
@@ -136,7 +140,6 @@ 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());
}
@@ -209,6 +212,18 @@ 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
@@ -289,7 +304,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 resolution downgrade
if (mode.getPhysicalWidth() < videoStream.getWidth() || mode.getPhysicalHeight() < videoStream.getHeight()) // Disallow reso downgrade
continue;
int rate = Math.round(mode.getRefreshRate() * 100);
@@ -387,7 +402,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
play(position, null);
}
protected void play(long position, @Nullable Integer forcedSubtitleIndex) {
private 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) {
@@ -502,21 +517,16 @@ public class PlaybackController implements PlaybackControllerNotifiable {
internalOptions.setMaxBitrate(maxBitrate);
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);
}
internalOptions.setSubtitleStreamIndex(forcedSubtitleIndex);
MediaSourceInfo currentMediaSource = getCurrentMediaSource();
if (!isLiveTv && currentMediaSource != null) {
internalOptions.setMediaSourceId(currentMediaSource.getId());
}
DeviceProfile internalProfile = new ExoPlayerProfile(
!internalOptions.getEnableDirectStream(),
isLiveTv && !userPreferences.getValue().get(UserPreferences.Companion.getLiveTvDirectPlayEnabled()),
userPreferences.getValue().get(UserPreferences.Companion.getAc3Enabled()),
userPreferences.getValue().get(UserPreferences.Companion.getAudioBehaviour()) == AudioBehavior.DOWNMIX_TO_STEREO
userPreferences.getValue().get(UserPreferences.Companion.getAudioBehaviour()) == AudioBehavior.DOWNMIX_TO_STEREO,
!DeviceUtils.has4kVideoSupport()
);
internalOptions.setProfile(internalProfile);
return internalOptions;
@@ -550,7 +560,6 @@ public class PlaybackController implements PlaybackControllerNotifiable {
if (mVideoManager == null)
return;
mCurrentOptions = internalOptions;
if (internalOptions.getSubtitleStreamIndex() == null) burningSubs = internalResponse.getSubtitleDeliveryMethod() == SubtitleDeliveryMethod.ENCODE;
startItem(item, position, internalResponse);
}
@@ -593,26 +602,29 @@ 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
mCurrentOptions.setSubtitleStreamIndex(response.getMediaSource().getDefaultSubtitleStreamIndex() != null ? response.getMediaSource().getDefaultSubtitleStreamIndex() : null);
mSubtitleStreams = response.getSubtitleProfiles(false, apiClient.getValue().getApiUrl(), apiClient.getValue().getAccessToken());
mDefaultSubIndex = response.getMediaSource().getDefaultSubtitleStreamIndex() != null ? response.getMediaSource().getDefaultSubtitleStreamIndex() : mDefaultSubIndex;
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", mCurrentOptions.getSubtitleStreamIndex(), response.getMediaSource().getDefaultSubtitleStreamIndex());
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);
}
Long mbPos = position * 10000;
@@ -628,30 +640,20 @@ public class PlaybackController implements PlaybackControllerNotifiable {
if (mFragment != null) mFragment.updateDisplay();
if (mVideoManager != null) {
mVideoManager.setMediaStreamInfo(api.getValue(), response);
mVideoManager.setVideoPath(response.getMediaUrl());
}
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();
//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();
}
}, 750);
dataRefreshService.getValue().setLastPlayedItem(item);
reportingHelper.getValue().reportStart(mFragment, PlaybackController.this, item, response, mbPos, false);
return null;
});
dataRefreshService.getValue().setLastPlayedItem(item);
reportingHelper.getValue().reportStart(item, mbPos);
}
public void startSpinner() {
@@ -742,6 +744,117 @@ 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
@@ -783,10 +896,8 @@ public class PlaybackController implements PlaybackControllerNotifiable {
mPlaybackState = PlaybackState.IDLE;
if (mVideoManager != null && mVideoManager.isPlaying()) mVideoManager.stopPlayback();
if (getCurrentlyPlayingItem() != null && mCurrentStreamInfo != null) {
Long mbPos = mCurrentPosition * 10000;
reportingHelper.getValue().reportStopped(mFragment, getCurrentlyPlayingItem(), mCurrentStreamInfo, mbPos);
}
Long mbPos = mCurrentPosition * 10000;
reportingHelper.getValue().reportStopped(getCurrentlyPlayingItem(), getCurrentStreamInfo(), mbPos);
clearPlaybackSessionOptions();
}
}
@@ -818,6 +929,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
}
private void clearPlaybackSessionOptions() {
mDefaultSubIndex = -1;
mDefaultAudioIndex = -1;
mSeekPosition = -1;
finishedInitialSeek = false;
@@ -863,11 +975,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
}
public void seek(long pos) {
seek(pos, false);
}
public void seek(long pos, boolean skipToNext) {
if (pos <= 0) pos = 0;
pos = Utils.getSafeSeekPosition(pos, getDuration());
Timber.d("Trying to seek from %s to %d", mCurrentPosition, pos);
Timber.d("Container: %s", mCurrentStreamInfo == null ? "unknown" : mCurrentStreamInfo.getContainer());
@@ -886,25 +994,9 @@ 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;
if (mCurrentStreamInfo == null) return;
// rebuild the stream
// if an older device uses exoplayer to play a transcoded stream but falls back to the generic http stream instead of hls, rebuild the stream
if (!mVideoManager.isSeekable()) {
@@ -918,7 +1010,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
public void onResponse(StreamInfo response) {
mCurrentStreamInfo = response;
if (mVideoManager != null) {
mVideoManager.setMediaStreamInfo(api.getValue(), response);
mVideoManager.setVideoPath(response.getMediaUrl());
mVideoManager.start();
}
}
@@ -982,7 +1074,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
BaseItemDto program = updatedChannel.getCurrentProgram();
if (program != null) {
mCurrentProgramEnd = program.getEndDate();
mCurrentProgramStart = program.getStartDate();
mCurrentProgramStart = program.getPremiereDate();
if (mFragment != null) mFragment.updateDisplay();
}
return null;
@@ -991,10 +1083,11 @@ public class PlaybackController implements PlaybackControllerNotifiable {
}
private long getRealTimeProgress() {
long progress = Instant.now().toEpochMilli();
if (mCurrentProgramStart != null) {
return Duration.between(mCurrentProgramStart, LocalDateTime.now()).toMillis();
progress -= mCurrentProgramStart.toInstant(ZoneOffset.UTC).toEpochMilli();
}
return 0;
return progress;
}
private long getTimeShiftedProgress() {
@@ -1003,10 +1096,8 @@ public class PlaybackController implements PlaybackControllerNotifiable {
}
private void startReportLoop() {
if (mCurrentStreamInfo == null) return;
stopReportLoop();
reportingHelper.getValue().reportProgress(mFragment, this, getCurrentlyPlayingItem(), getCurrentStreamInfo(), mCurrentPosition * 10000, false);
reportingHelper.getValue().reportProgress(this, getCurrentlyPlayingItem(), getCurrentStreamInfo(), mCurrentPosition * 10000, false);
mReportLoop = new Runnable() {
@Override
public void run() {
@@ -1014,7 +1105,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
refreshCurrentPosition();
long currentTime = isLiveTv ? getTimeShiftedProgress() : mCurrentPosition;
reportingHelper.getValue().reportProgress(mFragment, PlaybackController.this, getCurrentlyPlayingItem(), getCurrentStreamInfo(), currentTime * 10000, false);
reportingHelper.getValue().reportProgress(PlaybackController.this, getCurrentlyPlayingItem(), getCurrentStreamInfo(), currentTime * 10000, false);
}
if (mPlaybackState != PlaybackState.UNDEFINED && mPlaybackState != PlaybackState.IDLE) {
mHandler.postDelayed(this, PROGRESS_REPORTING_INTERVAL);
@@ -1026,8 +1117,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
private void startPauseReportLoop() {
stopReportLoop();
if (mCurrentStreamInfo == null) return;
reportingHelper.getValue().reportProgress(mFragment, this, getCurrentlyPlayingItem(), mCurrentStreamInfo, mCurrentPosition * 10000, true);
reportingHelper.getValue().reportProgress(this, getCurrentlyPlayingItem(), getCurrentStreamInfo(), mCurrentPosition * 10000, true);
mReportLoop = new Runnable() {
@Override
public void run() {
@@ -1048,7 +1138,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
mFragment.setSecondaryTime(getRealTimeProgress());
}
reportingHelper.getValue().reportProgress(mFragment, PlaybackController.this, currentItem, getCurrentStreamInfo(), currentTime * 10000, true);
reportingHelper.getValue().reportProgress(PlaybackController.this, currentItem, getCurrentStreamInfo(), currentTime * 10000, true);
mHandler.postDelayed(this, PROGRESS_REPORTING_PAUSE_INTERVAL);
}
};
@@ -1114,10 +1204,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
@Override
public void onPrepared() {
if (mPlaybackState == PlaybackState.BUFFERING) {
if (mFragment != null) {
mFragment.setFadingEnabled(true);
mFragment.leanbackOverlayFragment.setShouldShowOverlay(false);
}
if (mFragment != null) mFragment.setFadingEnabled(true);
mPlaybackState = PlaybackState.PLAYING;
mCurrentTranscodeStartTime = mCurrentStreamInfo.getPlayMethod() == PlayMethod.Transcode ? Instant.now().toEpochMilli() : 0;
@@ -1129,11 +1216,16 @@ public class PlaybackController implements PlaybackControllerNotifiable {
if (mPlaybackState == PlaybackState.PAUSED) {
mPlaybackState = PlaybackState.PLAYING;
} else {
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 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);
}
// select an audio track
@@ -1186,6 +1278,9 @@ public class PlaybackController implements PlaybackControllerNotifiable {
stopSpinner();
}
}
if (mFragment != null && finishedInitialSeek)
mFragment.updateSubtitles(mCurrentPosition);
}
if (mFragment != null)
mFragment.setCurrentTime(mCurrentPosition);
@@ -1223,11 +1318,11 @@ public class PlaybackController implements PlaybackControllerNotifiable {
return mPlaybackState == PlaybackState.PAUSED;
}
public @NonNull ZoomMode getZoomMode() {
return hasInitializedVideoManager() ? mVideoManager.getZoomMode() : ZoomMode.FIT;
public int getZoomMode() {
return hasInitializedVideoManager() ? mVideoManager.getZoomMode() : 0;
}
public void setZoom(@NonNull ZoomMode mode) {
public void setZoom(int mode) {
if (hasInitializedVideoManager())
mVideoManager.setZoom(mode);
}

View File

@@ -1,24 +1,11 @@
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(
@@ -35,166 +22,3 @@ 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?.firstOrNull { 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 { it != -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,10 +4,15 @@ 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;
@@ -53,12 +58,15 @@ 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));
apiClient.GetPlaybackInfoWithPost(request, new GetPlaybackInfoResponse(this, deviceInfo, apiClient, options, response, true, startPositionTicks));
}
public void changeVideoStream(final StreamInfo currentStreamInfo, DeviceInfo deviceInfo, final VideoOptions options, Long startPositionTicks, ApiClient apiClient, final Response<StreamInfo> response) {
@@ -66,4 +74,32 @@ 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,7 +2,6 @@ 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;
@@ -33,25 +32,19 @@ 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.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;
@@ -59,14 +52,19 @@ import timber.log.Timber;
@OptIn(markerClass = UnstableApi.class)
public class VideoManager {
private ZoomMode mZoomMode;
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 Activity mActivity;
private Equalizer mEqualizer;
private DynamicsProcessing mDynamicsProcessing;
private Limiter mLimiter;
private PlaybackControllerNotifiable mPlaybackControllerNotifiable;
private PlaybackOverlayFragmentHelper _helper;
public ExoPlayer mExoPlayer;
private ExoPlayer mExoPlayer;
private PlayerView mExoPlayerView;
private Handler mHandler = new Handler();
@@ -76,35 +74,23 @@ 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 = userPreferences.get(UserPreferences.Companion.getAudioNightMode());
nightModeEnabled = KoinJavaComponent.<UserPreferences>get(UserPreferences.class).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) {
@@ -139,7 +125,7 @@ public class VideoManager {
@Override
public void onPlaybackParametersChanged(@NonNull PlaybackParameters playbackParameters) {
if (mPlaybackControllerNotifiable != null) {
if (mPlaybackControllerNotifiable != null){
mPlaybackControllerNotifiable.onPlaybackSpeedChange(playbackParameters.speed);
}
}
@@ -164,18 +150,10 @@ 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.
@@ -187,7 +165,7 @@ public class VideoManager {
ExoPlayer.Builder exoPlayerBuilder = new ExoPlayer.Builder(context);
DefaultRenderersFactory defaultRendererFactory = new DefaultRenderersFactory(context);
defaultRendererFactory.setEnableDecoderFallback(true);
defaultRendererFactory.setExtensionRendererMode(determineExoPlayerExtensionRendererMode());
defaultRendererFactory.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON);
exoPlayerBuilder.setRenderersFactory(defaultRendererFactory);
DefaultTrackSelector trackSelector = new DefaultTrackSelector(context);
@@ -217,20 +195,20 @@ public class VideoManager {
return mExoPlayer != null;
}
public @NonNull ZoomMode getZoomMode() {
public int getZoomMode() {
return mZoomMode;
}
public void setZoom(@NonNull ZoomMode mode) {
public void setZoom(int mode) {
mZoomMode = mode;
switch (mode) {
case FIT:
case ZOOM_FIT:
mExoPlayerView.setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FIT);
break;
case AUTO_CROP:
case ZOOM_AUTO_CROP:
mExoPlayerView.setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_ZOOM);
break;
case STRETCH:
case ZOOM_STRETCH:
mExoPlayerView.setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FILL);
break;
}
@@ -292,6 +270,7 @@ public class VideoManager {
public void stopPlayback() {
if (mExoPlayer != null) {
mExoPlayer.stop();
disableSubs();
mExoPlayer.setTrackSelectionParameters(mExoPlayer.getTrackSelectionParameters()
.buildUpon()
@@ -320,15 +299,7 @@ public class VideoManager {
return pos;
}
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();
public void setVideoPath(@Nullable String path) {
if (path == null) {
Timber.w("Video path is null cannot continue");
return;
@@ -336,37 +307,23 @@ public class VideoManager {
Timber.i("Video path set to: %s", path);
try {
// 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(VideoManagerHelperKt.getSubtitleMediaStreamCodec(mediaStream))
.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.setMediaItem(MediaItem.fromUri(Uri.parse(path)));
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;
@@ -415,11 +372,7 @@ public class VideoManager {
if (trackFormat.id != null) {
int id;
try {
if (trackFormat.id.contains(":")) {
id = Integer.parseInt(trackFormat.id.split(":")[1]);
} else {
id = Integer.parseInt(trackFormat.id);
}
id = Integer.parseInt(trackFormat.id);
} catch (NumberFormatException e) {
Timber.d("failed to parse track ID [%s]", trackFormat.id);
break;
@@ -448,9 +401,13 @@ 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 && !stream.isExternal() && stream.getType() == streamType).findFirst();
Optional<MediaStream> candidateOptional = allStreams.stream().filter(stream -> stream.getIndex() == index).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;
@@ -480,18 +437,14 @@ 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 {
if (trackFormat.id.contains(":")) {
id = Integer.parseInt(trackFormat.id.split(":")[1]);
} else {
id = Integer.parseInt(trackFormat.id);
}
id = Integer.parseInt(trackFormat.id);
if (id != exoTrackID)
continue;
} catch (NumberFormatException e) {
@@ -520,6 +473,8 @@ 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");
@@ -528,7 +483,7 @@ public class VideoManager {
return true;
}
public float getPlaybackSpeed() {
public float getPlaybackSpeed(){
if (!isInitialized()) {
return 1.0f;
} else {
@@ -601,7 +556,6 @@ public class VideoManager {
}
private Runnable progressLoop;
private void startProgressLoop() {
stopProgressLoop();
progressLoop = new Runnable() {

View File

@@ -1,19 +0,0 @@
package org.jellyfin.androidtv.ui.playback
import androidx.core.net.toUri
import org.jellyfin.playback.media3.exoplayer.mapping.getFfmpegSubtitleMimeType
import org.jellyfin.sdk.model.api.MediaStream
/**
* Return the media type for the codec found in this media stream. First tries to infer the media type from the streams delivery URL and
* falls back to the original stream codec.
*/
fun getSubtitleMediaStreamCodec(stream: MediaStream): String {
val codec = requireNotNull(stream.codec)
val codecMediaType = getFfmpegSubtitleMimeType(codec, "").ifBlank { null }
val urlSubtitleExtension = stream.deliveryUrl?.toUri()?.lastPathSegment?.split('.')?.last()
val urlExtensionMediaType = urlSubtitleExtension?.let { getFfmpegSubtitleMimeType(it, "") }?.ifBlank { null }
return urlExtensionMediaType ?: codecMediaType ?: urlSubtitleExtension ?: codec
}

View File

@@ -109,13 +109,6 @@ public class CustomPlaybackTransportControlGlue extends PlaybackTransportControl
protected void onDetachedFromHost() {
mHandler.removeCallbacks(mRefreshEndTime);
mHandler.removeCallbacks(mRefreshViewVisibility);
closedCaptionsAction.removePopup();
playbackSpeedAction.dismissPopup();
selectAudioAction.dismissPopup();
selectQualityAction.dismissPopup();
zoomAction.dismissPopup();
super.onDetachedFromHost();
}
@@ -176,7 +169,6 @@ public class CustomPlaybackTransportControlGlue extends PlaybackTransportControl
super.onBindRowViewHolder(vh, item);
vh.setOnKeyListener(CustomPlaybackTransportControlGlue.this);
}
@Override
protected void onUnbindRowViewHolder(RowPresenter.ViewHolder vh) {
super.onUnbindRowViewHolder(vh);

View File

@@ -1,106 +1,21 @@
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() {
private val imageRequests = mutableMapOf<Int, Disposable>()
companion object {
private const val SEEK_LENGTH = 30000L
}
override fun getSeekPositions(): LongArray {
if (!videoPlayerAdapter.canSeek()) return LongArray(0)
val duration = videoPlayerAdapter.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()
val size = ceil(duration.toDouble() / SEEK_LENGTH.toDouble()).toInt() + 1
return LongArray(size) { i -> min(i * SEEK_LENGTH, duration) }
}
}

View File

@@ -3,20 +3,13 @@ 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;
@@ -25,10 +18,6 @@ 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) {
@@ -47,13 +36,6 @@ 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);
@@ -63,16 +45,9 @@ 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();
@@ -101,10 +76,7 @@ public class LeanbackOverlayFragment extends PlaybackSupportFragment {
playerGlue.invalidatePlaybackControls();
playerGlue.setSeekEnabled(playerAdapter.canSeek());
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);
playerGlue.setSeekProvider(playerAdapter.canSeek() ? new CustomSeekProvider(playerAdapter) : null);
recordingStateChanged();
playerAdapter.updateDuration();
}

View File

@@ -1,137 +0,0 @@
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,7 +9,6 @@ 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;
@@ -165,10 +164,6 @@ 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,17 +8,14 @@ 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(
context: Context,
customPlaybackTransportControlGlue: CustomPlaybackTransportControlGlue,
) : CustomAction(context, customPlaybackTransportControlGlue) {
private var popup: PopupMenu? = null
init {
initializeWithIcon(R.drawable.ic_select_subtitle)
}
@@ -36,37 +33,25 @@ class ClosedCaptionsAction(
}
videoPlayerAdapter.leanbackOverlayFragment.setFading(false)
removePopup()
popup = PopupMenu(context, view, Gravity.END).apply {
PopupMenu(context, view, Gravity.END).apply {
with(menu) {
var order = 0
add(0, -1, order++, 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 {
for (sub in playbackController.subtitleStreams) {
add(0, sub.index, sub.index, sub.displayTitle).apply {
isChecked = sub.index == playbackController.subtitleStreamIndex
}
}
add(0, -1, 0, context.getString(R.string.lbl_none)).apply {
isChecked = playbackController.subtitleStreamIndex == -1
}
setGroupCheckable(0, true, false)
}
setOnDismissListener {
videoPlayerAdapter.leanbackOverlayFragment.setFading(true)
popup = null
}
setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
setOnMenuItemClickListener { item ->
playbackController.setSubtitleIndex(item.itemId)
playbackController.switchSubtitleStream(item.itemId)
true
}
}
popup?.show()
}
fun removePopup() {
popup?.dismiss()
}.show()
}
}

View File

@@ -18,7 +18,6 @@ class PlaybackSpeedAction(
) : CustomAction(context, customPlaybackTransportControlGlue) {
private val speedController = VideoSpeedController(playbackController)
private val speeds = VideoSpeedController.SpeedSteps.entries.toTypedArray()
private var popup: PopupMenu? = null
init {
initializeWithIcon(R.drawable.ic_playback_speed)
@@ -31,20 +30,17 @@ class PlaybackSpeedAction(
view: View,
) {
videoPlayerAdapter.leanbackOverlayFragment.setFading(false)
dismissPopup()
popup = populateMenu(context, view, speedController)
val speedMenu = populateMenu(context, view, speedController)
popup?.setOnDismissListener {
videoPlayerAdapter.leanbackOverlayFragment.setFading(true)
popup = null
}
speedMenu.setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
popup?.setOnMenuItemClickListener { menuItem ->
speedMenu.setOnMenuItemClickListener { menuItem ->
speedController.currentSpeed = speeds[menuItem.itemId]
speedMenu.dismiss()
true
}
popup?.show()
speedMenu.show()
}
private fun populateMenu(
@@ -61,7 +57,4 @@ class PlaybackSpeedAction(
menu.getItem(speeds.indexOf(speedController.currentSpeed)).isChecked = true
}
fun dismissPopup() {
popup?.dismiss()
}
}

View File

@@ -8,6 +8,7 @@ import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.playback.PlaybackController
import org.jellyfin.androidtv.ui.playback.PlaybackManager
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 SelectAudioAction(
@@ -15,8 +16,6 @@ class SelectAudioAction(
customPlaybackTransportControlGlue: CustomPlaybackTransportControlGlue,
private val playbackManager: PlaybackManager,
) : CustomAction(context, customPlaybackTransportControlGlue) {
private var popup: PopupMenu? = null
init {
initializeWithIcon(R.drawable.ic_select_audio)
}
@@ -32,8 +31,7 @@ class SelectAudioAction(
?: return
val currentAudioIndex = playbackController.audioStreamIndex
dismissPopup()
popup = PopupMenu(context, view, Gravity.END).apply {
PopupMenu(context, view, Gravity.END).apply {
with(menu) {
for (track in audioTracks) {
add(0, track.index, track.index, track.displayTitle).apply {
@@ -43,19 +41,11 @@ class SelectAudioAction(
setGroupCheckable(0, true, false)
}
setOnDismissListener {
videoPlayerAdapter.leanbackOverlayFragment.setFading(true)
popup = null
}
setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
setOnMenuItemClickListener { item ->
playbackController.switchAudioStream(item.itemId)
true
}
}
popup?.show()
}
fun dismissPopup() {
popup?.dismiss()
}.show()
}
}

View File

@@ -20,7 +20,6 @@ class SelectQualityAction(
private val previousQualitySelection = userPreferences[UserPreferences.maxBitrate]
private val qualityController = VideoQualityController(previousQualitySelection, userPreferences)
private val qualityProfiles = getQualityProfiles(context)
private var popup: PopupMenu? = null
init {
initializeWithIcon(R.drawable.ic_select_quality)
@@ -33,8 +32,7 @@ class SelectQualityAction(
view: View,
) {
videoPlayerAdapter.leanbackOverlayFragment.setFading(false)
dismissPopup()
popup = PopupMenu(context, view, Gravity.END).apply {
PopupMenu(context, view, Gravity.END).apply {
qualityProfiles.values.forEachIndexed { i, selected ->
menu.add(0, i, i, selected)
}
@@ -47,21 +45,13 @@ class SelectQualityAction(
}
}
setOnDismissListener {
videoPlayerAdapter.leanbackOverlayFragment.setFading(true)
popup = null
}
setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
setOnMenuItemClickListener { menuItem ->
qualityController.currentQuality = qualityProfiles.keys.elementAt(menuItem.itemId)
playbackController.refreshStream()
dismiss()
true
}
}
popup?.show()
}
fun dismissPopup() {
popup?.dismiss()
}.show()
}
}

View File

@@ -5,18 +5,16 @@ 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,
customPlaybackTransportControlGlue: CustomPlaybackTransportControlGlue,
) : CustomAction(context, customPlaybackTransportControlGlue) {
private var popup: PopupMenu? = null
init {
initializeWithIcon(R.drawable.ic_aspect_ratio)
}
@@ -28,35 +26,43 @@ class ZoomAction(
view: View,
) {
videoPlayerAdapter.leanbackOverlayFragment.setFading(false)
dismissPopup()
popup = popupMenu(context, view, Gravity.END) {
item(context.getString(R.string.lbl_fit)) {
playbackController.setZoom(ZoomMode.FIT)
}.apply {
isChecked = playbackController.zoomMode == ZoomMode.FIT
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)
}
item(context.getString(R.string.lbl_auto_crop)) {
playbackController.setZoom(ZoomMode.AUTO_CROP)
}.apply {
isChecked = playbackController.zoomMode == ZoomMode.AUTO_CROP
setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
setOnMenuItemClickListener { item ->
playbackController.setZoom(item.itemId)
true
}
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 = null
}
popup?.show()
}
fun dismissPopup() {
popup?.dismiss()
}.show()
}
}

View File

@@ -16,7 +16,6 @@ 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
@@ -44,18 +43,17 @@ class PlaybackRewriteFragment : Fragment() {
super.onCreate(savedInstanceState)
// Create a queue from the items added to the legacy video 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)
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)
// Set position
val position = arguments?.getInt(EXTRA_POSITION) ?: 0
if (position != 0) {
lifecycleScope.launch {
Timber.i("Skipping to queue item $position")
playbackManager.queue.setIndex(position, false)
playbackManager.state.queue.setIndex(position, false)
}
}

View File

@@ -23,14 +23,14 @@ 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
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaType
import kotlin.math.max
@Suppress("TooManyFunctions")
class RewriteMediaManager(
@@ -39,7 +39,7 @@ class RewriteMediaManager(
private val navigationRepository: NavigationRepository,
private val playbackManager: PlaybackManager,
) : MediaManager {
private val queueSupplier = BaseItemQueueSupplier(api)
private val queue = BaseItemQueue(api)
override fun hasAudioQueueItems(): Boolean = currentAudioQueue.size() > 0 && currentAudioItem != null
@@ -47,19 +47,20 @@ class RewriteMediaManager(
get() = currentAudioQueue.size()
override val currentAudioQueuePosition: Int
get() = if ((playbackManager.queue.entryIndex.value) >= 0) 0 else -1
get() = if ((playbackManager.state.queue.entryIndex.value) >= 0) 0 else -1
override val currentAudioPosition: Long
get() = playbackManager.state.positionInfo.active.inWholeMilliseconds
override val currentAudioQueueDisplayPosition: String
get() = (playbackManager.queue.entryIndex.value + 1).toString()
get() = (currentAudioQueuePosition + 1).toString()
override val currentAudioQueueDisplaySize: String
get() = playbackManager.queue.estimatedSize.toString()
get() = ((playbackManager.state.queue.current.value as? BaseItemQueue)?.items?.size
?: currentAudioQueue.size()).toString()
override val currentAudioItem: BaseItemDto?
get() = playbackManager.queue.entry.value?.baseItem
get() = playbackManager.state.queue.entry.value?.baseItem
?.takeIf { it.mediaType == MediaType.AUDIO }
override fun toggleRepeat(): Boolean {
@@ -107,14 +108,12 @@ 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)
@@ -127,31 +126,32 @@ class RewriteMediaManager(
}
}
playbackManager.queue.entry.onEach { entry ->
val baseItem = entry?.baseItem
playbackManager.state.queue.current.onEach {
notifyListeners {
onQueueStatusChanged(baseItem?.mediaType == MediaType.AUDIO)
onQueueStatusChanged(hasAudioQueueItems())
}
}.launchIn(this)
playbackManager.queue.entry.onEach { updateAdapter() }.launchIn(this)
playbackManager.state.playbackOrder.onEach { updateAdapter() }.launchIn(this)
playbackManager.state.queue.entry.onEach { updateAdapter() }.launchIn(this)
}
private fun updateAdapter() {
val currentItem = playbackManager.queue.entry.value?.baseItem?.let(::AudioQueueBaseRowItem)?.apply {
playing = true
}
// It's safe to run this blocking as all items are prefetched via the [BaseItemQueueSupplier]
val upcomingItems = runBlocking { playbackManager.queue.peekNext(100) }
.mapIndexedNotNull { index, item -> item.baseItem?.let(::AudioQueueBaseRowItem) }
val items = listOfNotNull(currentItem) + upcomingItems
// Get all items as BaseRowItem
val items = queue
.items
// Map to audio queue items
.mapIndexed { index, item ->
AudioQueueBaseRowItem(item).apply {
playing = playbackManager.state.queue.entryIndex.value == index
}
}
// Remove items before currently playing item
.drop(max(0, playbackManager.state.queue.entryIndex.value))
// Update item row
currentAudioQueue.replaceAll(
items,
areItemsTheSame = { old, new -> (old as? AudioQueueBaseRowItem)?.baseItem?.id == (new as? AudioQueueBaseRowItem)?.baseItem?.id },
areItemsTheSame = { old, new -> (old as? AudioQueueBaseRowItem)?.baseItem == (new as? AudioQueueBaseRowItem)?.baseItem },
// The equals functions for BaseRowItem only compare by id
areContentsTheSame = { _, _ -> false },
)
@@ -185,26 +185,32 @@ class RewriteMediaManager(
override fun addToAudioQueue(items: List<BaseItemDto>) {
if (items.isEmpty()) return
queueSupplier.items.addAll(items)
val addIndex = when (playbackManager.state.playState.value) {
PlayState.PLAYING -> playbackManager.state.queue.entryIndex.value + 1
else -> 0
}
if (playbackManager.state.playState.value != PlayState.PLAYING) {
queue.items.addAll(addIndex, items)
if (
playbackManager.state.queue.current.value != queue ||
playbackManager.state.playState.value != PlayState.PLAYING
) {
playbackManager.state.setPlaybackOrder(if (isShuffleMode) PlaybackOrder.SHUFFLE else PlaybackOrder.DEFAULT)
playbackManager.queue.clear()
playbackManager.queue.addSupplier(queueSupplier)
playbackManager.state.play()
playbackManager.state.play(queue)
}
updateAdapter()
}
override fun removeFromAudioQueue(item: BaseItemDto) {
val index = queueSupplier.items.indexOf(item)
val index = queue.items.indexOf(item)
if (index == -1) return
// Disallow removing currently playing item (legacy UI cannot keep up)
if (playbackManager.queue.entryIndex.value == index) return
if (playbackManager.state.queue.entryIndex.value == index) return
queueSupplier.items.removeAt(index)
queue.items.removeAt(index)
updateAdapter()
}
@@ -213,21 +219,19 @@ class RewriteMediaManager(
override fun playNow(context: Context, items: List<BaseItemDto>, position: Int, shuffle: Boolean) {
val filteredItems = items.drop(position)
queueSupplier.items.clear()
queueSupplier.items.addAll(filteredItems)
queue.items.clear()
queue.items.addAll(filteredItems)
playbackManager.state.setPlaybackOrder(if (shuffle) PlaybackOrder.SHUFFLE else PlaybackOrder.DEFAULT)
playbackManager.queue.clear()
playbackManager.queue.addSupplier(queueSupplier)
playbackManager.state.play()
playbackManager.state.play(queue)
navigationRepository.navigate(Destinations.nowPlaying)
}
override fun playFrom(item: BaseItemDto): Boolean {
val index = queueSupplier.items.indexOf(item)
val index = queue.items.indexOf(item)
if (index == -1) return false
return runBlocking {
playbackManager.queue.setIndex(index) != null
playbackManager.state.queue.setIndex(index) != null
}
}
@@ -241,23 +245,23 @@ class RewriteMediaManager(
}
override fun hasNextAudioItem(): Boolean = runBlocking {
playbackManager.queue.peekNext() != null
playbackManager.state.queue.peekNext() != null
}
override fun hasPrevAudioItem(): Boolean = playbackManager.queue.entryIndex.value > 0
override fun hasPrevAudioItem(): Boolean = playbackManager.state.queue.entryIndex.value > 0
override fun nextAudioItem(): Int {
runBlocking { playbackManager.queue.next() }
runBlocking { playbackManager.state.queue.next() }
notifyListeners { onQueueStatusChanged(hasAudioQueueItems()) }
return playbackManager.queue.entryIndex.value
return playbackManager.state.queue.entryIndex.value
}
override fun prevAudioItem(): Int {
runBlocking { playbackManager.queue.previous() }
runBlocking { playbackManager.state.queue.previous() }
notifyListeners { onQueueStatusChanged(hasAudioQueueItems()) }
return playbackManager.queue.entryIndex.value
return playbackManager.state.queue.entryIndex.value
}
override fun stopAudio(releasePlayer: Boolean) {
@@ -266,17 +270,17 @@ class RewriteMediaManager(
override fun togglePlayPause() {
val playState = playbackManager.state.playState.value
if (playState == PlayState.PAUSED || playState == PlayState.STOPPED) playbackManager.state.unpause()
if (playState == PlayState.PAUSED) playbackManager.state.unpause()
else if (playState == PlayState.PLAYING) playbackManager.state.pause()
}
/**
* A simple [QueueSupplier] implementation for compatibility with existing UI/playback code. It contains
* A simple [Queue] implementation for compatibility with existing UI/playback code. It contains
* a mutable BaseItemDto list that is used to retrieve items from.
*/
class BaseItemQueueSupplier(
class BaseItemQueue(
private val api: ApiClient,
) : QueueSupplier {
) : Queue {
val items = mutableListOf<BaseItemDto>()
override val size: Int

View File

@@ -1,25 +0,0 @@
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

@@ -1,110 +0,0 @@
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,6 +9,7 @@ import java.util.UUID
class OptionsItemSeekbar(
private val context: Context
) : OptionsItemMutable<Int>() {
var content: String? = null
var min: Int = 0
var max: Int = 100
@@ -31,9 +32,8 @@ class OptionsItemSeekbar(
it.isEnabled = dependencyCheckFun() && enabled
it.isVisible = visible
it.title = title
// 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.max = max
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 coil3.ImageLoader
import coil3.annotation.ExperimentalCoilApi
import coil.ImageLoader
import coil.annotation.ExperimentalCoilApi
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.preference.SystemPreferences
@@ -50,20 +50,6 @@ 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,7 +21,15 @@ class HomePreferencesScreen : OptionsFragment() {
category {
setTitle(R.string.home_sections)
userSettingPreferences.homesections.forEachIndexed { index, section ->
arrayOf(
UserSettingPreferences.homesection0,
UserSettingPreferences.homesection1,
UserSettingPreferences.homesection2,
UserSettingPreferences.homesection3,
UserSettingPreferences.homesection4,
UserSettingPreferences.homesection5,
UserSettingPreferences.homesection6,
).forEachIndexed { index, section ->
enum<HomeSectionType> {
title = getString(R.string.home_section_i, index + 1)
bind(userSettingPreferences, section)

View File

@@ -5,14 +5,11 @@ 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
@@ -63,27 +60,6 @@ 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,12 +2,9 @@ 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
@@ -16,18 +13,10 @@ 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)
@@ -66,19 +55,6 @@ 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 {
@@ -93,96 +69,53 @@ 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_text_color)
setTitle(R.string.lbl_subtitle_fg)
entries = mapOf(
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),
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),
)
bind(userPreferences, UserPreferences.subtitlesTextColor)
}
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")
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)
}
@Suppress("MagicNumber")
// Stored in floats (1f = 100%) but seekbar preference works with integers only
seekbar {
setTitle(R.string.pref_subtitles_size)
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 }
}
}
min = 10
max = 38
bind(userPreferences, UserPreferences.subtitlesSize)
}
}

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 OnItemClickListener, we attach the OnClickListener.
// Only when having an OnItemClickListner, 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 structure.
* operations. And uses generics for strong typing. Uses a MutableList as internal stucture.
*/
open class MutableObjectAdapter<T : Any> : ObjectAdapter, Iterable<T> {
private val data = mutableListOf<T>()

View File

@@ -1,74 +0,0 @@
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)
Result.success(result.items.orEmpty())
} catch (e: ApiClientException) {
Timber.e("Failed to search for items", e)
Result.failure(e)

View File

@@ -0,0 +1,51 @@
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

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

View File

@@ -2,126 +2,32 @@ package org.jellyfin.androidtv.ui.startup.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Done
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
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.fragment.app.Fragment
import androidx.fragment.compose.content
import androidx.tv.material3.Button
import androidx.tv.material3.ButtonDefaults
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.SurfaceDefaults
import androidx.tv.material3.Text
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.composable.modifier.overscan
import org.jellyfin.androidtv.databinding.FragmentAlertConnectHelpBinding
@Composable
private fun ConnectHelpAlert(
onClose: () -> Unit,
) {
val focusRequester = remember { FocusRequester() }
class ConnectHelpAlertFragment : Fragment() {
private var _binding: FragmentAlertConnectHelpBinding? = null
private val binding get() = _binding!!
Surface(
colors = SurfaceDefaults.colors(
containerColor = colorResource(R.color.not_quite_black),
contentColor = colorResource(R.color.white),
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.overscan(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Column(
modifier = Modifier
.width(400.dp)
.align(Alignment.CenterVertically),
) {
Text(
text = stringResource(R.string.login_help_title),
style = MaterialTheme.typography.displayMedium,
)
Text(
modifier = Modifier.padding(top = 16.dp),
text = stringResource(R.string.login_help_description),
style = MaterialTheme.typography.bodyLarge,
)
Button(
modifier = Modifier
.padding(top = 24.dp)
.align(Alignment.Start)
.focusRequester(focusRequester),
onClick = onClose,
colors = ButtonDefaults.colors(
containerColor = colorResource(R.color.button_default_normal_background),
contentColor = colorResource(R.color.button_default_normal_text),
focusedContainerColor = colorResource(R.color.button_default_highlight_background),
focusedContentColor = colorResource(R.color.button_default_highlight_text),
disabledContainerColor = colorResource(R.color.button_default_disabled_background),
disabledContentColor = colorResource(R.color.button_default_disabled_text),
),
) {
Icon(
imageVector = Icons.Default.Done,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(
text = stringResource(id = R.string.btn_got_it),
style = MaterialTheme.typography.bodyLarge,
)
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentAlertConnectHelpBinding.inflate(inflater, container, false)
return binding.root
}
Image(
painter = painterResource(R.drawable.qr_jellyfin_docs),
contentDescription = stringResource(R.string.app_name),
modifier = Modifier
.width(200.dp)
.align(Alignment.CenterVertically),
)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
with(binding.confirm) {
requestFocus()
setOnClickListener { parentFragmentManager.popBackStack() }
}
}
LaunchedEffect(focusRequester) {
focusRequester.requestFocus()
}
}
override fun onDestroyView() {
super.onDestroyView()
class ConnectHelpAlertFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
) = content {
ConnectHelpAlert(
onClose = { parentFragmentManager.popBackStack() },
)
_binding = null
}
}

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