Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe7f0c62d3 | ||
|
|
16a382c6b5 | ||
|
|
bb42fd4801 | ||
|
|
ea16d8a6a4 | ||
|
|
eb869d291a | ||
|
|
8d2f928ad1 | ||
|
|
1c43f27bf7 | ||
|
|
3f8c4bf662 | ||
|
|
c3890f7ac0 | ||
|
|
ab165301d8 | ||
|
|
16dfd24849 | ||
|
|
9a189c57b7 | ||
|
|
d7d774ffbf | ||
|
|
271e0cf320 | ||
|
|
8b4275dea2 | ||
|
|
5b09d3b113 | ||
|
|
62f98901b0 | ||
|
|
19381684ae | ||
|
|
176a7e3391 | ||
|
|
2dbdbe8c28 | ||
|
|
1502dc805b | ||
|
|
29fc35f215 | ||
|
|
af0255440c | ||
|
|
389c48a1fd | ||
|
|
7d84011d81 | ||
|
|
36a307cd7f | ||
|
|
2a122803c2 | ||
|
|
dbf6dc0f9a | ||
|
|
fd663aff39 | ||
|
|
30e34568c1 | ||
|
|
5fb77314c4 | ||
|
|
d436c5de3d | ||
|
|
8157e3e5c1 | ||
|
|
3656c18e8f | ||
|
|
9f48b7da39 | ||
|
|
ef899f98bc | ||
|
|
de6c7904f0 | ||
|
|
0a79d5b60f | ||
|
|
36e0bc1edc | ||
|
|
8faeb3aac1 | ||
|
|
160ef4914f | ||
|
|
474c0774f1 | ||
|
|
0c9ba1ccec | ||
|
|
0c667ab615 |
12
.github/workflows/app-build.yaml
vendored
12
.github/workflows/app-build.yaml
vendored
@@ -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
|
||||
|
||||
12
.github/workflows/app-lint.yaml
vendored
12
.github/workflows/app-lint.yaml
vendored
@@ -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: .
|
||||
|
||||
12
.github/workflows/app-publish.yaml
vendored
12
.github/workflows/app-publish.yaml
vendored
@@ -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 }}
|
||||
|
||||
10
.github/workflows/app-test.yaml
vendored
10
.github/workflows/app-test.yaml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/gradlew-validate.yaml
vendored
6
.github/workflows/gradlew-validate.yaml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/repo-merge-conflict.yaml
vendored
2
.github/workflows/repo-merge-conflict.yaml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/repo-stale.yaml
vendored
2
.github/workflows/repo-stale.yaml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()) }
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
) { }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -709,7 +709,7 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
}
|
||||
});
|
||||
} else {
|
||||
mediaManager.getValue().queueAudioItem(baseItem);
|
||||
mediaManager.getValue().addToAudioQueue(Arrays.asList(baseItem));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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", " ")
|
||||
// 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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user