Date: Sun, 6 Jul 2025 13:51:59 +0200
Subject: [PATCH 25/87] Fix "Get it on F-Droid" appearing giant in README
---
README.md | 2 +-
doc/README.ar.md | 2 +-
doc/README.asm.md | 2 +-
doc/README.de.md | 2 +-
doc/README.es.md | 2 +-
doc/README.fr.md | 2 +-
doc/README.hi.md | 2 +-
doc/README.it.md | 2 +-
doc/README.ja.md | 2 +-
doc/README.ko.md | 2 +-
doc/README.pa.md | 2 +-
doc/README.pl.md | 2 +-
doc/README.pt_BR.md | 2 +-
doc/README.ro.md | 2 +-
doc/README.ru.md | 2 +-
doc/README.ryu.md | 2 +-
doc/README.so.md | 2 +-
doc/README.sr.md | 2 +-
doc/README.tr.md | 2 +-
doc/README.zh_TW.md | 2 +-
20 files changed, 20 insertions(+), 20 deletions(-)
diff --git a/README.md b/README.md
index 3cd7927af..095c3c43a 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
NewPipe
A libre lightweight streaming front-end for Android.
-
+
diff --git a/doc/README.ar.md b/doc/README.ar.md
index 8747d3e2c..f005050c7 100644
--- a/doc/README.ar.md
+++ b/doc/README.ar.md
@@ -2,7 +2,7 @@
NewPipe
.Android واجهة أمامية متدفقة خفيفة الوزن لنظام
-
+
diff --git a/doc/README.asm.md b/doc/README.asm.md
index c2d919d09..37d0949b7 100644
--- a/doc/README.asm.md
+++ b/doc/README.asm.md
@@ -2,7 +2,7 @@
NewPipe
এণ্ড্ৰইডৰ বাবে এটা লিব্ৰে লাইটৱেট ষ্ট্ৰীমিং ফ্ৰন্ট-এণ্ড।
-
+
diff --git a/doc/README.de.md b/doc/README.de.md
index 03dd2b364..5cbb4e6dd 100644
--- a/doc/README.de.md
+++ b/doc/README.de.md
@@ -5,7 +5,7 @@
NewPipe
Eine freie, offene und leichtgewichtige Streaming App für Android.
-
+
diff --git a/doc/README.es.md b/doc/README.es.md
index 338b3242a..4a08cba08 100644
--- a/doc/README.es.md
+++ b/doc/README.es.md
@@ -2,7 +2,7 @@
NewPipe
Una interfaz de streaming ligera y libre para Android.
-
+
diff --git a/doc/README.fr.md b/doc/README.fr.md
index ee3621e27..cfebcb2a6 100644
--- a/doc/README.fr.md
+++ b/doc/README.fr.md
@@ -5,7 +5,7 @@
NewPipe
Un front-end de streaming libre et léger pour Android.
-
+
diff --git a/doc/README.hi.md b/doc/README.hi.md
index ed56fca14..6098c6c26 100644
--- a/doc/README.hi.md
+++ b/doc/README.hi.md
@@ -2,7 +2,7 @@
NewPipe
Android के लिए एक ओपन सोर्स, हल्का YouTube ऐप।
-
+
diff --git a/doc/README.it.md b/doc/README.it.md
index 930959c77..d926db6bc 100644
--- a/doc/README.it.md
+++ b/doc/README.it.md
@@ -2,7 +2,7 @@
NewPipe
Un frontend di streaming libero e leggero per Android.
-
+
diff --git a/doc/README.ja.md b/doc/README.ja.md
index 19902d57e..1e751855b 100644
--- a/doc/README.ja.md
+++ b/doc/README.ja.md
@@ -2,7 +2,7 @@
NewPipe
自由で軽量な Android 向けストリーミングフロントエンド
-
+
diff --git a/doc/README.ko.md b/doc/README.ko.md
index 3c2f9f39e..39fb7e11c 100644
--- a/doc/README.ko.md
+++ b/doc/README.ko.md
@@ -2,7 +2,7 @@
NewPipe
A libre lightweight streaming frontend for Android.
-
+
diff --git a/doc/README.pa.md b/doc/README.pa.md
index 2dbc94c14..9b84ded18 100644
--- a/doc/README.pa.md
+++ b/doc/README.pa.md
@@ -2,7 +2,7 @@
NewPipe
ਐਂਡਰੌਇਡ ਲਈ ਇੱਕ ਮੁਫ਼ਤ ਹਲਕਾ-ਫੁਲਕਾ ਸਟ੍ਰੀਮਿੰਗ ਯੂਟਿਊਬ ਫਰੰਟ-ਐਂਡ।
-
+
diff --git a/doc/README.pl.md b/doc/README.pl.md
index 9d216c590..9574491c7 100644
--- a/doc/README.pl.md
+++ b/doc/README.pl.md
@@ -2,7 +2,7 @@
NewPipe
Wolny, lekki streamingowy frontend na Androida.
-
+
diff --git a/doc/README.pt_BR.md b/doc/README.pt_BR.md
index d65fa9790..b73da2de1 100644
--- a/doc/README.pt_BR.md
+++ b/doc/README.pt_BR.md
@@ -6,7 +6,7 @@
NewPipe
Uma interface de streaming leve e gratuita para Android.
-
+
diff --git a/doc/README.ro.md b/doc/README.ro.md
index 5363ef7bc..3f146f7e4 100644
--- a/doc/README.ro.md
+++ b/doc/README.ro.md
@@ -2,7 +2,7 @@
NewPipe
Un front-end de streaming „uşor” liber, pentru Android.
-
+
diff --git a/doc/README.ru.md b/doc/README.ru.md
index 894e5f2e0..8a9955707 100644
--- a/doc/README.ru.md
+++ b/doc/README.ru.md
@@ -2,7 +2,7 @@
NewPipe
Свободный и легковесный клиент потоковых сервисов для Android.
-
+
diff --git a/doc/README.ryu.md b/doc/README.ryu.md
index 8676f1bfd..f3ca31af0 100644
--- a/doc/README.ryu.md
+++ b/doc/README.ryu.md
@@ -2,7 +2,7 @@
NewPipe
じゆーいっしけいりょうなAndroidんきーストリーミングフロントエンド
-
+
diff --git a/doc/README.so.md b/doc/README.so.md
index 82e544d93..843bed749 100644
--- a/doc/README.so.md
+++ b/doc/README.so.md
@@ -2,7 +2,7 @@
NewPipe
App bilaash ah oo fudud looguna talagalay in Android-ka wax loogu daawado.
-
+
diff --git a/doc/README.sr.md b/doc/README.sr.md
index d8b0fe435..21e4d857c 100644
--- a/doc/README.sr.md
+++ b/doc/README.sr.md
@@ -5,7 +5,7 @@
NewPipe
Бесплатна и лагана апликација за стримовање за Android.
-
+
diff --git a/doc/README.tr.md b/doc/README.tr.md
index c6610d97d..6e95e54de 100644
--- a/doc/README.tr.md
+++ b/doc/README.tr.md
@@ -2,7 +2,7 @@
NewPipe
Android için hafif ve özgür bir akış arayüzü.
-
+
diff --git a/doc/README.zh_TW.md b/doc/README.zh_TW.md
index 04a8355cb..05518624f 100644
--- a/doc/README.zh_TW.md
+++ b/doc/README.zh_TW.md
@@ -2,7 +2,7 @@
NewPipe
輕巧的 Android 串流前端
-
+
From 834c93f22add0046470583625d788518a0553fe2 Mon Sep 17 00:00:00 2001
From: Stypox
Date: Sun, 6 Jul 2025 14:49:09 +0200
Subject: [PATCH 26/87] Fix thumbnails appearing on Android Auto even if
disabled
---
.../player/mediabrowser/MediaBrowserImpl.kt | 19 ++++++++++++++-----
1 file changed, 14 insertions(+), 5 deletions(-)
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt
index 3108da80f..c52f78250 100644
--- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt
+++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt
@@ -8,6 +8,7 @@ import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat
import android.util.Log
import androidx.annotation.DrawableRes
+import androidx.core.net.toUri
import androidx.media.MediaBrowserServiceCompat
import androidx.media.MediaBrowserServiceCompat.Result
import androidx.media.utils.MediaConstants
@@ -185,7 +186,7 @@ class MediaBrowserImpl(
builder
.setMediaId(createMediaIdForInfoItem(playlist is PlaylistRemoteEntity, playlist.uid))
.setTitle(playlist.orderingName)
- .setIconUri(playlist.thumbnailUrl?.let { Uri.parse(it) })
+ .setIconUri(imageUriOrNullIfDisabled(playlist.thumbnailUrl))
val extras = Bundle()
extras.putString(
@@ -212,7 +213,7 @@ class MediaBrowserImpl(
}
ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
- builder.setIconUri(Uri.parse(it))
+ builder.setIconUri(imageUriOrNullIfDisabled(it))
}
return MediaBrowserCompat.MediaItem(
@@ -258,7 +259,7 @@ class MediaBrowserImpl(
builder.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index))
.setTitle(item.streamEntity.title)
.setSubtitle(item.streamEntity.uploader)
- .setIconUri(Uri.parse(item.streamEntity.thumbnailUrl))
+ .setIconUri(imageUriOrNullIfDisabled(item.streamEntity.thumbnailUrl))
return MediaBrowserCompat.MediaItem(
builder.build(),
@@ -277,7 +278,7 @@ class MediaBrowserImpl(
.setSubtitle(item.uploaderName)
ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
- builder.setIconUri(Uri.parse(it))
+ builder.setIconUri(imageUriOrNullIfDisabled(it))
}
return MediaBrowserCompat.MediaItem(
@@ -316,7 +317,7 @@ class MediaBrowserImpl(
builder.setMediaId(mediaId)
.setTitle(streamHistoryEntry.streamEntity.title)
.setSubtitle(streamHistoryEntry.streamEntity.uploader)
- .setIconUri(Uri.parse(streamHistoryEntry.streamEntity.thumbnailUrl))
+ .setIconUri(imageUriOrNullIfDisabled(streamHistoryEntry.streamEntity.thumbnailUrl))
return MediaBrowserCompat.MediaItem(
builder.build(),
@@ -395,5 +396,13 @@ class MediaBrowserImpl(
companion object {
private val TAG: String = MediaBrowserImpl::class.java.getSimpleName()
+
+ fun imageUriOrNullIfDisabled(url: String?): Uri? {
+ return if (ImageStrategy.shouldLoadImages()) {
+ url?.toUri()
+ } else {
+ null
+ }
+ }
}
}
From a4d457b2b2eac7ca9a0b30b430327c05cc61cb8c Mon Sep 17 00:00:00 2001
From: Stypox
Date: Sun, 6 Jul 2025 14:49:49 +0200
Subject: [PATCH 27/87] Use Kotlin's .toUri() instead of Uri.parse()
---
.../org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt | 2 +-
.../player/mediabrowser/MediaBrowserPlaybackPreparer.kt | 3 ++-
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt
index c52f78250..f15d7ab08 100644
--- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt
+++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt
@@ -104,7 +104,7 @@ class MediaBrowserImpl(
private fun onLoadChildren(parentId: String): Single> {
try {
- val parentIdUri = Uri.parse(parentId)
+ val parentIdUri = parentId.toUri()
val path = ArrayList(parentIdUri.pathSegments)
if (path.isEmpty()) {
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt
index f34677a29..a3791e2e7 100644
--- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt
+++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt
@@ -6,6 +6,7 @@ import android.os.Bundle
import android.os.ResultReceiver
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log
+import androidx.core.net.toUri
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.PlaybackPreparer
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
@@ -137,7 +138,7 @@ class MediaBrowserPlaybackPreparer(
private fun extractPlayQueueFromMediaId(mediaId: String): Single {
try {
- val mediaIdUri = Uri.parse(mediaId)
+ val mediaIdUri = mediaId.toUri()
val path = ArrayList(mediaIdUri.pathSegments)
if (path.isEmpty()) {
throw parseError(mediaId)
From 705b5e558096b8321dbac36ba952cec3dedb7b30 Mon Sep 17 00:00:00 2001
From: Stypox
Date: Mon, 7 Jul 2025 01:04:45 +0200
Subject: [PATCH 28/87] Fix ghost notifications on Android 10
Fixes #12400, see there for explanation. Citing from there:
So apparently the problem is onGetRoot always returning a BrowserRoot instance. Making it return null solved the issue (but again, breaks Android Auto compatibility). It turns out (see https://stackoverflow.com/q/63818988/) that onGetRoot is also used for media resumption https://developer.android.com/media/implement/surfaces/mobile#mediabrowserservice_implementation, which causes a new notification to pop up (in this case a useless notification because our onGetRoot does not return something that can be used for resumption). So what needs to be done is to check if rootHints?.getBoolean(EXTRA_RECENT) == true and if that's the case not return anything (as EXTRA_RECENT is used by the system for resumption).
The PackageValidator file is taken from https://github.com/android/uamp/blob/329a21b63c247e9bd35f6858d4fc0e448fa38603/common/src/main/java/com/example/android/uamp/media/PackageValidator.kt .
---
.../schabi/newpipe/player/PlayerService.java | 1 -
.../player/mediabrowser/MediaBrowserImpl.kt | 15 +-
.../player/mediabrowser/PackageValidator.kt | 243 ++++++++++++++++++
3 files changed, 257 insertions(+), 2 deletions(-)
create mode 100644 app/src/main/java/org/schabi/newpipe/player/mediabrowser/PackageValidator.kt
diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java b/app/src/main/java/org/schabi/newpipe/player/PlayerService.java
index 1888bce01..5455d4c19 100644
--- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java
+++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.java
@@ -330,7 +330,6 @@ public final class PlayerService extends MediaBrowserServiceCompat {
public BrowserRoot onGetRoot(@NonNull final String clientPackageName,
final int clientUid,
@Nullable final Bundle rootHints) {
- // TODO check if the accessing package has permission to view data
return mediaBrowserImpl.onGetRoot(clientPackageName, clientUid, rootHints);
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt
index 3108da80f..3b4e5e07b 100644
--- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt
+++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt
@@ -9,6 +9,7 @@ import android.support.v4.media.MediaDescriptionCompat
import android.util.Log
import androidx.annotation.DrawableRes
import androidx.media.MediaBrowserServiceCompat
+import androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT
import androidx.media.MediaBrowserServiceCompat.Result
import androidx.media.utils.MediaConstants
import io.reactivex.rxjava3.core.Flowable
@@ -47,6 +48,7 @@ class MediaBrowserImpl(
private val context: Context,
notifyChildrenChanged: Consumer, // parentId
) {
+ private val packageValidator = PackageValidator(context)
private val database = NewPipeDatabase.getInstance(context)
private var disposables = CompositeDisposable()
@@ -68,11 +70,22 @@ class MediaBrowserImpl(
clientPackageName: String,
clientUid: Int,
rootHints: Bundle?
- ): MediaBrowserServiceCompat.BrowserRoot {
+ ): MediaBrowserServiceCompat.BrowserRoot? {
if (DEBUG) {
Log.d(TAG, "onGetRoot($clientPackageName, $clientUid, $rootHints)")
}
+ if (!packageValidator.isKnownCaller(clientPackageName, clientUid)) {
+ // this is a caller we can't trust (see PackageValidator's rules taken from uamp)
+ return null
+ }
+
+ if (rootHints?.getBoolean(EXTRA_RECENT, false) == true) {
+ // the system is asking for a root to do media resumption, but we can't handle that yet,
+ // see https://developer.android.com/media/implement/surfaces/mobile#mediabrowserservice_implementation
+ return null
+ }
+
val extras = Bundle()
extras.putBoolean(
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/PackageValidator.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/PackageValidator.kt
new file mode 100644
index 000000000..973b11b37
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/PackageValidator.kt
@@ -0,0 +1,243 @@
+/*
+ * Copyright 2018 Google Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// THIS FILE WAS TAKEN FROM UAMP, EXCEPT FOR THINGS RELATED TO THE WHITELIST. UPDATE IT WHEN NEEDED.
+// https://github.com/android/uamp/blob/329a21b63c247e9bd35f6858d4fc0e448fa38603/common/src/main/java/com/example/android/uamp/media/PackageValidator.kt
+
+package org.schabi.newpipe.player.mediabrowser
+
+import android.Manifest.permission.MEDIA_CONTENT_CONTROL
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.pm.PackageInfo
+import android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED
+import android.content.pm.PackageManager
+import android.os.Process
+import android.support.v4.media.session.MediaSessionCompat
+import android.util.Log
+import androidx.core.app.NotificationManagerCompat
+import androidx.media.MediaBrowserServiceCompat
+import org.schabi.newpipe.BuildConfig
+import java.security.MessageDigest
+import java.security.NoSuchAlgorithmException
+
+/**
+ * Validates that the calling package is authorized to browse a [MediaBrowserServiceCompat].
+ *
+ * The list of allowed signing certificates and their corresponding package names is defined in
+ * res/xml/allowed_media_browser_callers.xml.
+ *
+ * If you want to add a new caller to allowed_media_browser_callers.xml and you don't know
+ * its signature, this class will print to logcat (INFO level) a message with the proper
+ * xml tags to add to allow the caller.
+ *
+ * For more information, see res/xml/allowed_media_browser_callers.xml.
+ */
+internal class PackageValidator(context: Context) {
+ private val context: Context = context.applicationContext
+ private val packageManager: PackageManager = this.context.packageManager
+ private val platformSignature: String = getSystemSignature()
+ private val callerChecked = mutableMapOf>()
+
+ /**
+ * Checks whether the caller attempting to connect to a [MediaBrowserServiceCompat] is known.
+ * See [MusicService.onGetRoot] for where this is utilized.
+ *
+ * @param callingPackage The package name of the caller.
+ * @param callingUid The user id of the caller.
+ * @return `true` if the caller is known, `false` otherwise.
+ */
+ fun isKnownCaller(callingPackage: String, callingUid: Int): Boolean {
+ // If the caller has already been checked, return the previous result here.
+ val (checkedUid, checkResult) = callerChecked[callingPackage] ?: Pair(0, false)
+ if (checkedUid == callingUid) {
+ return checkResult
+ }
+
+ /**
+ * Because some of these checks can be slow, we save the results in [callerChecked] after
+ * this code is run.
+ *
+ * In particular, there's little reason to recompute the calling package's certificate
+ * signature (SHA-256) each call.
+ *
+ * This is safe to do as we know the UID matches the package's UID (from the check above),
+ * and app UIDs are set at install time. Additionally, a package name + UID is guaranteed to
+ * be constant until a reboot. (After a reboot then a previously assigned UID could be
+ * reassigned.)
+ */
+
+ // Build the caller info for the rest of the checks here.
+ val callerPackageInfo = buildCallerInfo(callingPackage)
+ ?: throw IllegalStateException("Caller wasn't found in the system?")
+
+ // Verify that things aren't ... broken. (This test should always pass.)
+ if (callerPackageInfo.uid != callingUid) {
+ throw IllegalStateException("Caller's package UID doesn't match caller's actual UID?")
+ }
+
+ val callerSignature = callerPackageInfo.signature
+
+ val isCallerKnown = when {
+ // If it's our own app making the call, allow it.
+ callingUid == Process.myUid() -> true
+ // If the system is making the call, allow it.
+ callingUid == Process.SYSTEM_UID -> true
+ // If the app was signed by the same certificate as the platform itself, also allow it.
+ callerSignature == platformSignature -> true
+ /**
+ * [MEDIA_CONTENT_CONTROL] permission is only available to system applications, and
+ * while it isn't required to allow these apps to connect to a
+ * [MediaBrowserServiceCompat], allowing this ensures optimal compatability with apps
+ * such as Android TV and the Google Assistant.
+ */
+ callerPackageInfo.permissions.contains(MEDIA_CONTENT_CONTROL) -> true
+ /**
+ * If the calling app has a notification listener it is able to retrieve notifications
+ * and can connect to an active [MediaSessionCompat].
+ *
+ * It's not required to allow apps with a notification listener to
+ * connect to your [MediaBrowserServiceCompat], but it does allow easy compatibility
+ * with apps such as Wear OS.
+ */
+ NotificationManagerCompat.getEnabledListenerPackages(this.context)
+ .contains(callerPackageInfo.packageName) -> true
+
+ // If none of the previous checks succeeded, then the caller is unrecognized.
+ else -> false
+ }
+
+ if (!isCallerKnown) {
+ logUnknownCaller(callerPackageInfo)
+ }
+
+ // Save our work for next time.
+ callerChecked[callingPackage] = Pair(callingUid, isCallerKnown)
+ return isCallerKnown
+ }
+
+ /**
+ * Logs an info level message with details of how to add a caller to the allowed callers list
+ * when the app is debuggable.
+ */
+ private fun logUnknownCaller(callerPackageInfo: CallerPackageInfo) {
+ if (BuildConfig.DEBUG) {
+ Log.w(TAG, "Unknown caller $callerPackageInfo")
+ }
+ }
+
+ /**
+ * Builds a [CallerPackageInfo] for a given package that can be used for all the
+ * various checks that are performed before allowing an app to connect to a
+ * [MediaBrowserServiceCompat].
+ */
+ private fun buildCallerInfo(callingPackage: String): CallerPackageInfo? {
+ val packageInfo = getPackageInfo(callingPackage) ?: return null
+
+ val appName = packageInfo.applicationInfo.loadLabel(packageManager).toString()
+ val uid = packageInfo.applicationInfo.uid
+ val signature = getSignature(packageInfo)
+
+ val requestedPermissions = packageInfo.requestedPermissions
+ val permissionFlags = packageInfo.requestedPermissionsFlags
+ val activePermissions = mutableSetOf()
+ requestedPermissions?.forEachIndexed { index, permission ->
+ if (permissionFlags[index] and REQUESTED_PERMISSION_GRANTED != 0) {
+ activePermissions += permission
+ }
+ }
+
+ return CallerPackageInfo(appName, callingPackage, uid, signature, activePermissions.toSet())
+ }
+
+ /**
+ * Looks up the [PackageInfo] for a package name.
+ * This requests both the signatures (for checking if an app is on the allow list) and
+ * the app's permissions, which allow for more flexibility in the allow list.
+ *
+ * @return [PackageInfo] for the package name or null if it's not found.
+ */
+ @Suppress("deprecation")
+ @SuppressLint("PackageManagerGetSignatures")
+ private fun getPackageInfo(callingPackage: String): PackageInfo? =
+ packageManager.getPackageInfo(
+ callingPackage,
+ PackageManager.GET_SIGNATURES or PackageManager.GET_PERMISSIONS
+ )
+
+ /**
+ * Gets the signature of a given package's [PackageInfo].
+ *
+ * The "signature" is a SHA-256 hash of the public key of the signing certificate used by
+ * the app.
+ *
+ * If the app is not found, or if the app does not have exactly one signature, this method
+ * returns `null` as the signature.
+ */
+ @Suppress("deprecation")
+ private fun getSignature(packageInfo: PackageInfo): String? =
+ if (packageInfo.signatures == null || packageInfo.signatures.size != 1) {
+ // Security best practices dictate that an app should be signed with exactly one (1)
+ // signature. Because of this, if there are multiple signatures, reject it.
+ null
+ } else {
+ val certificate = packageInfo.signatures[0].toByteArray()
+ getSignatureSha256(certificate)
+ }
+
+ /**
+ * Finds the Android platform signing key signature. This key is never null.
+ */
+ private fun getSystemSignature(): String =
+ getPackageInfo(ANDROID_PLATFORM)?.let { platformInfo ->
+ getSignature(platformInfo)
+ } ?: throw IllegalStateException("Platform signature not found")
+
+ /**
+ * Creates a SHA-256 signature given a certificate byte array.
+ */
+ private fun getSignatureSha256(certificate: ByteArray): String {
+ val md: MessageDigest
+ try {
+ md = MessageDigest.getInstance("SHA256")
+ } catch (noSuchAlgorithmException: NoSuchAlgorithmException) {
+ Log.e(TAG, "No such algorithm: $noSuchAlgorithmException")
+ throw RuntimeException("Could not find SHA256 hash algorithm", noSuchAlgorithmException)
+ }
+ md.update(certificate)
+
+ // This code takes the byte array generated by `md.digest()` and joins each of the bytes
+ // to a string, applying the string format `%02x` on each digit before it's appended, with
+ // a colon (':') between each of the items.
+ // For example: input=[0,2,4,6,8,10,12], output="00:02:04:06:08:0a:0c"
+ return md.digest().joinToString(":") { String.format("%02x", it) }
+ }
+
+ /**
+ * Convenience class to hold all of the information about an app that's being checked
+ * to see if it's a known caller.
+ */
+ private data class CallerPackageInfo(
+ val name: String,
+ val packageName: String,
+ val uid: Int,
+ val signature: String?,
+ val permissions: Set
+ )
+}
+
+private const val TAG = "PackageValidator"
+private const val ANDROID_PLATFORM = "android"
From 79084568f2d827588b963a246e623830ebdca4d1 Mon Sep 17 00:00:00 2001
From: Stypox
Date: Mon, 7 Jul 2025 15:07:46 +0200
Subject: [PATCH 29/87] Fix fullscreen eliciting "clear queue" prompt
---
.../org/schabi/newpipe/player/helper/PlayerHolder.java | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
index 20a0f3766..97f2d6717 100644
--- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
@@ -159,6 +159,11 @@ public final class PlayerHolder {
private boolean playAfterConnect = false;
+ /**
+ * @param playAfterConnection Sets the value of `playAfterConnect` to pass to the {@link
+ * PlayerServiceExtendedEventListener#onPlayerConnected(Player, boolean)} the next time it
+ * is called. The value of `playAfterConnect` will be reset to false after that.
+ */
public void doPlayAfterConnect(final boolean playAfterConnection) {
this.playAfterConnect = playAfterConnection;
}
@@ -183,7 +188,6 @@ public final class PlayerHolder {
playerService = localBinder.getService();
if (listener != null) {
listener.onServiceConnected(playerService);
- getPlayer().ifPresent(p -> listener.onPlayerConnected(p, playAfterConnect));
}
startPlayerListener();
// ^ will call listener.onPlayerConnected() down the line if there is an active player
@@ -357,6 +361,8 @@ public final class PlayerHolder {
listener.onPlayerDisconnected();
} else {
listener.onPlayerConnected(player, serviceConnection.playAfterConnect);
+ // reset the value of playAfterConnect: if it was true before, it is now "consumed"
+ serviceConnection.playAfterConnect = false;
player.setFragmentListener(internalListener);
}
}
From f0b26e208bef1b0b1710d219f8eb5ac8ffaa00b5 Mon Sep 17 00:00:00 2001
From: Stypox
Date: Sun, 6 Jul 2025 14:00:54 +0200
Subject: [PATCH 30/87] Update notice about rewrite in the README
---
README.md | 4 ++--
doc/README.de.md | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 095c3c43a..c19144064 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-We are planning to rewrite large chunks of the codebase, to bring about a new, modern and stable NewPipe!
-Please do not open pull requests for new features now, only bugfix PRs will be accepted.
+We are rewriting large chunks of the codebase, to bring about a modern and stable NewPipe! You can download nightly builds here.
+Please work on the refactor branch if you want to contribute new features. The current codebase is in maintenance mode and will only receive bugfixes.

NewPipe
diff --git a/doc/README.de.md b/doc/README.de.md
index 5cbb4e6dd..34ad94ab1 100644
--- a/doc/README.de.md
+++ b/doc/README.de.md
@@ -1,5 +1,5 @@
-Wir planen große Teile des Quellcodes neu zu schreiben, um NewPipe neu, modern und stabiler zu machen!
-Öffne keine neuen Pull Requests für neue Features, es werden nur Fehlerbehebungen akzeptiert.
+Wir sind im Prozess, größere Teile unseres Codes neuzuschreiben, um eine moderne und stabile NewPipe App zu kreieren! Du kannst nightly builds hier herunterladen.
+Bitte nutze den refactor branch als Arbeitsgrundlage, wenn du neue Funktionen beitragen willst. Die aktuelle Codebase ist im reinen Maintenance mode und bekommt nur noch Fehlerbehebungen.

NewPipe
From 72b67ab5d4992f9618fb81569290880e4d6f04fd Mon Sep 17 00:00:00 2001
From: Isira Seneviratne
Date: Mon, 7 Jul 2025 07:52:54 +0530
Subject: [PATCH 31/87] Refactor zip import/export using Path
---
.../BackupRestoreSettingsFragment.java | 12 ++----
.../settings/export/BackupFileLocator.kt | 19 ++++-----
.../settings/export/ImportExportManager.kt | 29 ++++++-------
.../org/schabi/newpipe/util/ZipHelper.java | 41 ++++++-------------
.../settings/ImportAllCombinationsTest.kt | 16 ++++----
.../settings/ImportExportManagerTest.kt | 36 +++++++++-------
6 files changed, 67 insertions(+), 86 deletions(-)
diff --git a/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java
index 97df1549b..cc93e9227 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java
@@ -35,7 +35,6 @@ import org.schabi.newpipe.streams.io.StoredFileHelper;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.ZipHelper;
-import java.io.File;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
@@ -61,13 +60,12 @@ public class BackupRestoreSettingsFragment extends BasePreferenceFragment {
@Override
public void onCreatePreferences(@Nullable final Bundle savedInstanceState,
@Nullable final String rootKey) {
- final File homeDir = ContextCompat.getDataDir(requireContext());
- Objects.requireNonNull(homeDir);
- manager = new ImportExportManager(new BackupFileLocator(homeDir));
+ final var dbDir = Objects.requireNonNull(ContextCompat.getDataDir(requireContext()))
+ .toPath();
+ manager = new ImportExportManager(new BackupFileLocator(dbDir));
importExportDataPathKey = getString(R.string.import_export_data_path);
-
addPreferencesFromResourceRegistry();
final Preference importDataPreference = requirePreference(R.string.import_data);
@@ -183,9 +181,7 @@ public class BackupRestoreSettingsFragment extends BasePreferenceFragment {
}
try {
- if (!manager.ensureDbDirectoryExists()) {
- throw new IOException("Could not create databases dir");
- }
+ manager.ensureDbDirectoryExists();
// replace the current database
if (!manager.extractDb(file)) {
diff --git a/app/src/main/java/org/schabi/newpipe/settings/export/BackupFileLocator.kt b/app/src/main/java/org/schabi/newpipe/settings/export/BackupFileLocator.kt
index c864e4a0d..0ce2d5f4d 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/export/BackupFileLocator.kt
+++ b/app/src/main/java/org/schabi/newpipe/settings/export/BackupFileLocator.kt
@@ -1,11 +1,12 @@
package org.schabi.newpipe.settings.export
-import java.io.File
+import java.nio.file.Path
+import kotlin.io.path.div
/**
* Locates specific files of NewPipe based on the home directory of the app.
*/
-class BackupFileLocator(private val homeDir: File) {
+class BackupFileLocator(homeDir: Path) {
companion object {
const val FILE_NAME_DB = "newpipe.db"
@Deprecated(
@@ -16,13 +17,9 @@ class BackupFileLocator(private val homeDir: File) {
const val FILE_NAME_JSON_PREFS = "preferences.json"
}
- val dbDir by lazy { File(homeDir, "/databases") }
-
- val db by lazy { File(dbDir, FILE_NAME_DB) }
-
- val dbJournal by lazy { File(dbDir, "$FILE_NAME_DB-journal") }
-
- val dbShm by lazy { File(dbDir, "$FILE_NAME_DB-shm") }
-
- val dbWal by lazy { File(dbDir, "$FILE_NAME_DB-wal") }
+ val dbDir = homeDir / "databases"
+ val db = homeDir / FILE_NAME_DB
+ val dbJournal = homeDir / "$FILE_NAME_DB-journal"
+ val dbShm = dbDir / "$FILE_NAME_DB-shm"
+ val dbWal = dbDir / "$FILE_NAME_DB-wal"
}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt b/app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt
index 36e0b9ce1..04562bc77 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt
+++ b/app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt
@@ -12,6 +12,8 @@ import java.io.FileNotFoundException
import java.io.IOException
import java.io.ObjectOutputStream
import java.util.zip.ZipOutputStream
+import kotlin.io.path.createDirectories
+import kotlin.io.path.deleteExisting
class ImportExportManager(private val fileLocator: BackupFileLocator) {
companion object {
@@ -28,11 +30,8 @@ class ImportExportManager(private val fileLocator: BackupFileLocator) {
// previous file size, the file will retain part of the previous content and be corrupted
ZipOutputStream(SharpOutputStream(file.openAndTruncateStream()).buffered()).use { outZip ->
// add the database
- ZipHelper.addFileToZip(
- outZip,
- BackupFileLocator.FILE_NAME_DB,
- fileLocator.db.path,
- )
+ val name = BackupFileLocator.FILE_NAME_DB
+ ZipHelper.addFileToZip(outZip, name, fileLocator.db)
// add the legacy vulnerable serialized preferences (will be removed in the future)
ZipHelper.addFileToZip(
@@ -61,11 +60,10 @@ class ImportExportManager(private val fileLocator: BackupFileLocator) {
/**
* Tries to create database directory if it does not exist.
- *
- * @return Whether the directory exists afterwards.
*/
- fun ensureDbDirectoryExists(): Boolean {
- return fileLocator.dbDir.exists() || fileLocator.dbDir.mkdir()
+ @Throws(IOException::class)
+ fun ensureDbDirectoryExists() {
+ fileLocator.dbDir.createDirectories()
}
/**
@@ -75,16 +73,13 @@ class ImportExportManager(private val fileLocator: BackupFileLocator) {
* @return true if the database was successfully extracted, false otherwise
*/
fun extractDb(file: StoredFileHelper): Boolean {
- val success = ZipHelper.extractFileFromZip(
- file,
- BackupFileLocator.FILE_NAME_DB,
- fileLocator.db.path,
- )
+ val name = BackupFileLocator.FILE_NAME_DB
+ val success = ZipHelper.extractFileFromZip(file, name, fileLocator.db)
if (success) {
- fileLocator.dbJournal.delete()
- fileLocator.dbWal.delete()
- fileLocator.dbShm.delete()
+ fileLocator.dbJournal.deleteExisting()
+ fileLocator.dbWal.deleteExisting()
+ fileLocator.dbShm.deleteExisting()
}
return success
diff --git a/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java b/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java
index b2aebac42..771452c89 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java
@@ -6,12 +6,12 @@ import org.schabi.newpipe.streams.io.StoredFileHelper;
import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
@@ -55,17 +55,17 @@ public final class ZipHelper {
/**
- * This function helps to create zip files. Caution this will overwrite the original file.
+ * This function helps to create zip files. Caution, this will overwrite the original file.
*
* @param outZip the ZipOutputStream where the data should be stored in
* @param nameInZip the path of the file inside the zip
- * @param fileOnDisk the path of the file on the disk that should be added to zip
+ * @param path the path of the file on the disk that should be added to zip
*/
public static void addFileToZip(final ZipOutputStream outZip,
final String nameInZip,
- final String fileOnDisk) throws IOException {
- try (FileInputStream fi = new FileInputStream(fileOnDisk)) {
- addFileToZip(outZip, nameInZip, fi);
+ final Path path) throws IOException {
+ try (var inputStream = Files.newInputStream(path)) {
+ addFileToZip(outZip, nameInZip, inputStream);
}
}
@@ -113,33 +113,18 @@ public final class ZipHelper {
}
/**
- * This will extract data from ZipInputStream. Caution this will overwrite the original file.
+ * This will extract data from ZipInputStream. Caution, this will overwrite the original file.
*
* @param zipFile the zip file to extract from
* @param nameInZip the path of the file inside the zip
- * @param fileOnDisk the path of the file on the disk where the data should be extracted to
+ * @param path the path of the file on the disk where the data should be extracted to
* @return will return true if the file was found within the zip file
*/
public static boolean extractFileFromZip(final StoredFileHelper zipFile,
final String nameInZip,
- final String fileOnDisk) throws IOException {
- return extractFileFromZip(zipFile, nameInZip, input -> {
- // delete old file first
- final File oldFile = new File(fileOnDisk);
- if (oldFile.exists()) {
- if (!oldFile.delete()) {
- throw new IOException("Could not delete " + fileOnDisk);
- }
- }
-
- final byte[] data = new byte[BUFFER_SIZE];
- try (FileOutputStream outFile = new FileOutputStream(fileOnDisk)) {
- int count;
- while ((count = input.read(data)) != -1) {
- outFile.write(data, 0, count);
- }
- }
- });
+ final Path path) throws IOException {
+ return extractFileFromZip(zipFile, nameInZip, input ->
+ Files.copy(input, path, StandardCopyOption.REPLACE_EXISTING));
}
/**
diff --git a/app/src/test/java/org/schabi/newpipe/settings/ImportAllCombinationsTest.kt b/app/src/test/java/org/schabi/newpipe/settings/ImportAllCombinationsTest.kt
index 862ac3b80..2d8a29acb 100644
--- a/app/src/test/java/org/schabi/newpipe/settings/ImportAllCombinationsTest.kt
+++ b/app/src/test/java/org/schabi/newpipe/settings/ImportAllCombinationsTest.kt
@@ -10,7 +10,9 @@ import org.schabi.newpipe.streams.io.StoredFileHelper
import us.shandian.giga.io.FileStream
import java.io.File
import java.io.IOException
-import java.nio.file.Files
+import kotlin.io.path.createTempFile
+import kotlin.io.path.exists
+import kotlin.io.path.fileSize
class ImportAllCombinationsTest {
@@ -47,10 +49,10 @@ class ImportAllCombinationsTest {
BackupFileLocator::class.java,
Mockito.withSettings().stubOnly()
)
- val db = File.createTempFile("newpipe_", "")
- val dbJournal = File.createTempFile("newpipe_", "")
- val dbWal = File.createTempFile("newpipe_", "")
- val dbShm = File.createTempFile("newpipe_", "")
+ val db = createTempFile("newpipe_", "")
+ val dbJournal = createTempFile("newpipe_", "")
+ val dbWal = createTempFile("newpipe_", "")
+ val dbShm = createTempFile("newpipe_", "")
Mockito.`when`(fileLocator.db).thenReturn(db)
Mockito.`when`(fileLocator.dbJournal).thenReturn(dbJournal)
Mockito.`when`(fileLocator.dbShm).thenReturn(dbShm)
@@ -62,7 +64,7 @@ class ImportAllCombinationsTest {
Assert.assertFalse(dbJournal.exists())
Assert.assertFalse(dbWal.exists())
Assert.assertFalse(dbShm.exists())
- Assert.assertTrue("database file size is zero", Files.size(db.toPath()) > 0)
+ Assert.assertTrue("database file size is zero", db.fileSize() > 0)
}
} else {
runTest {
@@ -70,7 +72,7 @@ class ImportAllCombinationsTest {
Assert.assertTrue(dbJournal.exists())
Assert.assertTrue(dbWal.exists())
Assert.assertTrue(dbShm.exists())
- Assert.assertEquals(0, Files.size(db.toPath()))
+ Assert.assertEquals(0, db.fileSize())
}
}
diff --git a/app/src/test/java/org/schabi/newpipe/settings/ImportExportManagerTest.kt b/app/src/test/java/org/schabi/newpipe/settings/ImportExportManagerTest.kt
index 5b8023561..f362963db 100644
--- a/app/src/test/java/org/schabi/newpipe/settings/ImportExportManagerTest.kt
+++ b/app/src/test/java/org/schabi/newpipe/settings/ImportExportManagerTest.kt
@@ -25,8 +25,14 @@ import org.schabi.newpipe.streams.io.StoredFileHelper
import us.shandian.giga.io.FileStream
import java.io.File
import java.io.ObjectInputStream
-import java.nio.file.Files
import java.util.zip.ZipFile
+import kotlin.io.path.Path
+import kotlin.io.path.createTempDirectory
+import kotlin.io.path.createTempFile
+import kotlin.io.path.deleteIfExists
+import kotlin.io.path.exists
+import kotlin.io.path.fileSize
+import kotlin.io.path.inputStream
@RunWith(MockitoJUnitRunner::class)
class ImportExportManagerTest {
@@ -46,7 +52,7 @@ class ImportExportManagerTest {
@Test
fun `The settings must be exported successfully in the correct format`() {
- val db = File(classloader.getResource("settings/newpipe.db")!!.file)
+ val db = Path(classloader.getResource("settings/newpipe.db")!!.file)
`when`(fileLocator.db).thenReturn(db)
val expectedPreferences = mapOf("such pref" to "much wow")
@@ -81,8 +87,8 @@ class ImportExportManagerTest {
@Test
fun `Ensuring db directory existence must work`() {
- val dir = Files.createTempDirectory("newpipe_").toFile()
- Assume.assumeTrue(dir.delete())
+ val dir = createTempDirectory("newpipe_")
+ Assume.assumeTrue(dir.deleteIfExists())
`when`(fileLocator.dbDir).thenReturn(dir)
ImportExportManager(fileLocator).ensureDbDirectoryExists()
@@ -91,7 +97,7 @@ class ImportExportManagerTest {
@Test
fun `Ensuring db directory existence must work when the directory already exists`() {
- val dir = Files.createTempDirectory("newpipe_").toFile()
+ val dir = createTempDirectory("newpipe_")
`when`(fileLocator.dbDir).thenReturn(dir)
ImportExportManager(fileLocator).ensureDbDirectoryExists()
@@ -100,10 +106,10 @@ class ImportExportManagerTest {
@Test
fun `The database must be extracted from the zip file`() {
- val db = File.createTempFile("newpipe_", "")
- val dbJournal = File.createTempFile("newpipe_", "")
- val dbWal = File.createTempFile("newpipe_", "")
- val dbShm = File.createTempFile("newpipe_", "")
+ val db = createTempFile("newpipe_", "")
+ val dbJournal = createTempFile("newpipe_", "")
+ val dbWal = createTempFile("newpipe_", "")
+ val dbShm = createTempFile("newpipe_", "")
`when`(fileLocator.db).thenReturn(db)
`when`(fileLocator.dbJournal).thenReturn(dbJournal)
`when`(fileLocator.dbShm).thenReturn(dbShm)
@@ -117,15 +123,15 @@ class ImportExportManagerTest {
assertFalse(dbJournal.exists())
assertFalse(dbWal.exists())
assertFalse(dbShm.exists())
- assertTrue("database file size is zero", Files.size(db.toPath()) > 0)
+ assertTrue("database file size is zero", db.fileSize() > 0)
}
@Test
fun `Extracting the database from an empty zip must not work`() {
- val db = File.createTempFile("newpipe_", "")
- val dbJournal = File.createTempFile("newpipe_", "")
- val dbWal = File.createTempFile("newpipe_", "")
- val dbShm = File.createTempFile("newpipe_", "")
+ val db = createTempFile("newpipe_", "")
+ val dbJournal = createTempFile("newpipe_", "")
+ val dbWal = createTempFile("newpipe_", "")
+ val dbShm = createTempFile("newpipe_", "")
`when`(fileLocator.db).thenReturn(db)
val emptyZip = File(classloader.getResource("settings/nodb_noser_nojson.zip")?.file!!)
@@ -136,7 +142,7 @@ class ImportExportManagerTest {
assertTrue(dbJournal.exists())
assertTrue(dbWal.exists())
assertTrue(dbShm.exists())
- assertEquals(0, Files.size(db.toPath()))
+ assertEquals(0, db.fileSize())
}
@Test
From 3e106b5e4feea5c3b54a3df06188507c4e10a329 Mon Sep 17 00:00:00 2001
From: Isira Seneviratne
Date: Mon, 7 Jul 2025 07:53:20 +0530
Subject: [PATCH 32/87] Fix DB import/export issue
---
.../newpipe/settings/BackupRestoreSettingsFragment.java | 6 ++----
.../schabi/newpipe/settings/export/ImportExportManager.kt | 8 ++++----
2 files changed, 6 insertions(+), 8 deletions(-)
diff --git a/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java
index cc93e9227..fca158c28 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java
@@ -17,7 +17,6 @@ import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-import androidx.core.content.ContextCompat;
import androidx.preference.Preference;
import androidx.preference.PreferenceManager;
@@ -39,7 +38,6 @@ import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
-import java.util.Objects;
public class BackupRestoreSettingsFragment extends BasePreferenceFragment {
@@ -60,8 +58,8 @@ public class BackupRestoreSettingsFragment extends BasePreferenceFragment {
@Override
public void onCreatePreferences(@Nullable final Bundle savedInstanceState,
@Nullable final String rootKey) {
- final var dbDir = Objects.requireNonNull(ContextCompat.getDataDir(requireContext()))
- .toPath();
+ final var dbDir = requireContext().getDatabasePath(BackupFileLocator.FILE_NAME_DB).toPath()
+ .getParent();
manager = new ImportExportManager(new BackupFileLocator(dbDir));
importExportDataPathKey = getString(R.string.import_export_data_path);
diff --git a/app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt b/app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt
index 04562bc77..6b0eb7eb9 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt
+++ b/app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt
@@ -13,7 +13,7 @@ import java.io.IOException
import java.io.ObjectOutputStream
import java.util.zip.ZipOutputStream
import kotlin.io.path.createDirectories
-import kotlin.io.path.deleteExisting
+import kotlin.io.path.deleteIfExists
class ImportExportManager(private val fileLocator: BackupFileLocator) {
companion object {
@@ -77,9 +77,9 @@ class ImportExportManager(private val fileLocator: BackupFileLocator) {
val success = ZipHelper.extractFileFromZip(file, name, fileLocator.db)
if (success) {
- fileLocator.dbJournal.deleteExisting()
- fileLocator.dbWal.deleteExisting()
- fileLocator.dbShm.deleteExisting()
+ fileLocator.dbJournal.deleteIfExists()
+ fileLocator.dbWal.deleteIfExists()
+ fileLocator.dbShm.deleteIfExists()
}
return success
From 225cb91105eb024cab54a3e76a545e5b1d7c6a1c Mon Sep 17 00:00:00 2001
From: Isira Seneviratne
Date: Mon, 7 Jul 2025 08:06:52 +0530
Subject: [PATCH 33/87] Use InputStream#transferTo()
---
.../org/schabi/newpipe/util/ZipHelper.java | 28 ++++++-------------
1 file changed, 9 insertions(+), 19 deletions(-)
diff --git a/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java b/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java
index 771452c89..e53fbe52a 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java
@@ -37,9 +37,6 @@ import java.util.zip.ZipOutputStream;
*/
public final class ZipHelper {
-
- private static final int BUFFER_SIZE = 2048;
-
@FunctionalInterface
public interface InputStreamConsumer {
void acceptStream(InputStream inputStream) throws IOException;
@@ -80,13 +77,13 @@ public final class ZipHelper {
final String nameInZip,
final OutputStreamConsumer streamConsumer) throws IOException {
final byte[] bytes;
- try (ByteArrayOutputStream byteOutput = new ByteArrayOutputStream()) {
+ try (var byteOutput = new ByteArrayOutputStream()) {
streamConsumer.acceptStream(byteOutput);
bytes = byteOutput.toByteArray();
}
- try (ByteArrayInputStream byteInput = new ByteArrayInputStream(bytes)) {
- ZipHelper.addFileToZip(outZip, nameInZip, byteInput);
+ try (var byteInput = new ByteArrayInputStream(bytes)) {
+ addFileToZip(outZip, nameInZip, byteInput);
}
}
@@ -97,19 +94,12 @@ public final class ZipHelper {
* @param nameInZip the path of the file inside the zip
* @param inputStream the content to put inside the file
*/
- public static void addFileToZip(final ZipOutputStream outZip,
- final String nameInZip,
- final InputStream inputStream) throws IOException {
- final byte[] data = new byte[BUFFER_SIZE];
- try (BufferedInputStream bufferedInputStream =
- new BufferedInputStream(inputStream, BUFFER_SIZE)) {
- final ZipEntry entry = new ZipEntry(nameInZip);
- outZip.putNextEntry(entry);
- int count;
- while ((count = bufferedInputStream.read(data, 0, BUFFER_SIZE)) != -1) {
- outZip.write(data, 0, count);
- }
- }
+ private static void addFileToZip(final ZipOutputStream outZip,
+ final String nameInZip,
+ final InputStream inputStream) throws IOException {
+ final var entry = new ZipEntry(nameInZip);
+ outZip.putNextEntry(entry);
+ inputStream.transferTo(outZip);
}
/**
From 4ffadc20577fce88a3ffad49fac78c0e1f1d90c0 Mon Sep 17 00:00:00 2001
From: Isira Seneviratne
Date: Tue, 8 Jul 2025 06:21:42 +0530
Subject: [PATCH 34/87] Inline variable
---
app/src/main/java/org/schabi/newpipe/util/ZipHelper.java | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java b/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java
index e53fbe52a..bccfc7f38 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java
@@ -97,8 +97,7 @@ public final class ZipHelper {
private static void addFileToZip(final ZipOutputStream outZip,
final String nameInZip,
final InputStream inputStream) throws IOException {
- final var entry = new ZipEntry(nameInZip);
- outZip.putNextEntry(entry);
+ outZip.putNextEntry(new ZipEntry(nameInZip));
inputStream.transferTo(outZip);
}
From 690af88db9be1899827f2f3a12dc9e782eb5d08c Mon Sep 17 00:00:00 2001
From: Isira Seneviratne
Date: Wed, 11 Jun 2025 06:19:52 +0530
Subject: [PATCH 35/87] Add PlayQueueItem equals and hashCode
---
.../fragments/detail/VideoDetailFragment.java | 13 ++++----
.../newpipe/player/playqueue/PlayQueue.java | 31 ++++++-------------
.../player/playqueue/PlayQueueItem.java | 13 ++++++++
.../player/playqueue/PlayQueueItemTest.java | 10 +++---
.../player/playqueue/PlayQueueTest.java | 12 ++++---
5 files changed, 40 insertions(+), 39 deletions(-)
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
index 17ae16325..fb6e2d79e 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
@@ -852,8 +852,7 @@ public final class VideoDetailFragment
if (playQueue == null) {
playQueue = new SinglePlayQueue(result);
}
- if (stack.isEmpty() || !stack.peek().getPlayQueue()
- .equalStreams(playQueue)) {
+ if (stack.isEmpty() || !stack.peek().getPlayQueue().equals(playQueue)) {
stack.push(new StackItem(serviceId, url, title, playQueue));
}
}
@@ -1739,7 +1738,7 @@ public final class VideoDetailFragment
// deleted/added items inside Channel/Playlist queue and makes possible to have
// a history of played items
@Nullable final StackItem stackPeek = stack.peek();
- if (stackPeek != null && !stackPeek.getPlayQueue().equalStreams(queue)) {
+ if (stackPeek != null && !stackPeek.getPlayQueue().equals(queue)) {
@Nullable final PlayQueueItem playQueueItem = queue.getItem();
if (playQueueItem != null) {
stack.push(new StackItem(playQueueItem.getServiceId(), playQueueItem.getUrl(),
@@ -1803,7 +1802,7 @@ public final class VideoDetailFragment
// They are not equal when user watches something in popup while browsing in fragment and
// then changes screen orientation. In that case the fragment will set itself as
// a service listener and will receive initial call to onMetadataUpdate()
- if (!queue.equalStreams(playQueue)) {
+ if (!queue.equals(playQueue)) {
return;
}
@@ -2073,9 +2072,10 @@ public final class VideoDetailFragment
private StackItem findQueueInStack(final PlayQueue queue) {
StackItem item = null;
final Iterator iterator = stack.descendingIterator();
+
while (iterator.hasNext()) {
final StackItem next = iterator.next();
- if (next.getPlayQueue().equalStreams(queue)) {
+ if (next.getPlayQueue().equals(queue)) {
item = next;
break;
}
@@ -2089,8 +2089,7 @@ public final class VideoDetailFragment
// Player will have STATE_IDLE when a user pressed back button
if (isClearingQueueConfirmationRequired(activity)
&& playerIsNotStopped()
- && activeQueue != null
- && !activeQueue.equalStreams(playQueue)) {
+ && !Objects.equals(activeQueue, playQueue)) {
showClearingQueueConfirmation(onAllow);
} else {
onAllow.run();
diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java
index cfa2ab316..2d28d240f 100644
--- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java
+++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java
@@ -518,31 +518,18 @@ public abstract class PlayQueue implements Serializable {
* This method also gives a chance to track history of items in a queue in
* VideoDetailFragment without duplicating items from two identical queues
*/
- public boolean equalStreams(@Nullable final PlayQueue other) {
- if (other == null) {
- return false;
- }
- if (size() != other.size()) {
- return false;
- }
- for (int i = 0; i < size(); i++) {
- final PlayQueueItem stream = streams.get(i);
- final PlayQueueItem otherStream = other.streams.get(i);
- // Check is based on serviceId and URL
- if (stream.getServiceId() != otherStream.getServiceId()
- || !stream.getUrl().equals(otherStream.getUrl())) {
- return false;
- }
- }
- return true;
+ @Override
+ public boolean equals(final Object o) {
+ return o instanceof PlayQueue playQueue && streams.equals(playQueue.streams);
+ }
+
+ @Override
+ public int hashCode() {
+ return streams.hashCode();
}
public boolean equalStreamsAndIndex(@Nullable final PlayQueue other) {
- if (equalStreams(other)) {
- //noinspection ConstantConditions
- return other.getIndex() == getIndex(); //NOSONAR: other is not null
- }
- return false;
+ return equals(other) && other.getIndex() == getIndex();
}
public boolean isDisposed() {
diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java
index 759c51267..8f41ceb60 100644
--- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java
+++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java
@@ -11,6 +11,7 @@ import org.schabi.newpipe.util.ExtractorHelper;
import java.io.Serializable;
import java.util.List;
+import java.util.Objects;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
@@ -139,4 +140,16 @@ public class PlayQueueItem implements Serializable {
public void setAutoQueued(final boolean autoQueued) {
isAutoQueued = autoQueued;
}
+
+ @Override
+ public boolean equals(final Object o) {
+ return o instanceof PlayQueueItem item
+ && serviceId == item.serviceId
+ && url.equals(item.url);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(url, serviceId);
+ }
}
diff --git a/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTest.java b/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTest.java
index d10d33f7e..ef1b36d32 100644
--- a/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTest.java
+++ b/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTest.java
@@ -1,19 +1,17 @@
package org.schabi.newpipe.player.playqueue;
-import org.junit.Test;
-
import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotEquals;
+
+import org.junit.Test;
public class PlayQueueItemTest {
public static final String URL = "MY_URL";
@Test
- public void equalsMustNotBeOverloaded() {
+ public void equalsMustWork() {
final PlayQueueItem a = PlayQueueTest.makeItemWithUrl(URL);
final PlayQueueItem b = PlayQueueTest.makeItemWithUrl(URL);
- assertEquals(a, a);
- assertNotEquals(a, b); // they should compare different even if they have the same data
+ assertEquals(a, b);
}
}
diff --git a/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueTest.java b/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueTest.java
index 022089f37..24212b786 100644
--- a/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueTest.java
+++ b/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueTest.java
@@ -3,6 +3,8 @@ package org.schabi.newpipe.player.playqueue;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
+import org.junit.experimental.runners.Enclosed;
+import org.junit.runner.RunWith;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.extractor.stream.StreamType;
@@ -13,12 +15,14 @@ import java.util.Objects;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.spy;
+@RunWith(Enclosed.class)
@SuppressWarnings("checkstyle:HideUtilityClassConstructor")
public class PlayQueueTest {
static PlayQueue makePlayQueue(final int index, final List streams) {
@@ -168,7 +172,7 @@ public class PlayQueueTest {
final List streams = Collections.nCopies(5, item1);
final PlayQueue queue1 = makePlayQueue(0, streams);
final PlayQueue queue2 = makePlayQueue(0, streams);
- assertTrue(queue1.equalStreams(queue2));
+ assertEquals(queue1, queue2);
assertTrue(queue1.equalStreamsAndIndex(queue2));
}
@@ -177,7 +181,7 @@ public class PlayQueueTest {
final List streams = Collections.nCopies(5, item1);
final PlayQueue queue1 = makePlayQueue(1, streams);
final PlayQueue queue2 = makePlayQueue(4, streams);
- assertTrue(queue1.equalStreams(queue2));
+ assertEquals(queue1, queue2);
assertFalse(queue1.equalStreamsAndIndex(queue2));
}
@@ -187,7 +191,7 @@ public class PlayQueueTest {
final List streams2 = Collections.nCopies(5, item2);
final PlayQueue queue1 = makePlayQueue(0, streams1);
final PlayQueue queue2 = makePlayQueue(0, streams2);
- assertFalse(queue1.equalStreams(queue2));
+ assertNotEquals(queue1, queue2);
}
@Test
@@ -196,7 +200,7 @@ public class PlayQueueTest {
final List streams2 = Collections.nCopies(6, item2);
final PlayQueue queue1 = makePlayQueue(0, streams1);
final PlayQueue queue2 = makePlayQueue(0, streams2);
- assertFalse(queue1.equalStreams(queue2));
+ assertNotEquals(queue1, queue2);
}
}
}
From e2a02a1f869995492aa7d7459a1f48eaae36b85c Mon Sep 17 00:00:00 2001
From: Isira Seneviratne
Date: Wed, 11 Jun 2025 07:39:29 +0530
Subject: [PATCH 36/87] Fix some issues
---
.../schabi/newpipe/fragments/detail/VideoDetailFragment.java | 1 -
.../schabi/newpipe/player/playqueue/PlayQueueItemTest.java | 4 ++--
2 files changed, 2 insertions(+), 3 deletions(-)
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
index fb6e2d79e..5e0373122 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
@@ -2072,7 +2072,6 @@ public final class VideoDetailFragment
private StackItem findQueueInStack(final PlayQueue queue) {
StackItem item = null;
final Iterator iterator = stack.descendingIterator();
-
while (iterator.hasNext()) {
final StackItem next = iterator.next();
if (next.getPlayQueue().equals(queue)) {
diff --git a/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTest.java b/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTest.java
index ef1b36d32..9addcfc1e 100644
--- a/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTest.java
+++ b/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTest.java
@@ -1,9 +1,9 @@
package org.schabi.newpipe.player.playqueue;
-import static org.junit.Assert.assertEquals;
-
import org.junit.Test;
+import static org.junit.Assert.assertEquals;
+
public class PlayQueueItemTest {
public static final String URL = "MY_URL";
From bb7873d157524bf62541e755ee3c8879f14045c5 Mon Sep 17 00:00:00 2001
From: Isira Seneviratne
Date: Wed, 11 Jun 2025 08:13:13 +0530
Subject: [PATCH 37/87] Fix Sonar warning
---
.../java/org/schabi/newpipe/player/playqueue/PlayQueue.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java
index 2d28d240f..a474b624b 100644
--- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java
+++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java
@@ -529,7 +529,7 @@ public abstract class PlayQueue implements Serializable {
}
public boolean equalStreamsAndIndex(@Nullable final PlayQueue other) {
- return equals(other) && other.getIndex() == getIndex();
+ return equals(other) && other.getIndex() == getIndex(); //NOSONAR: other is not null
}
public boolean isDisposed() {
From c2b6c71947e766b388fdf8bf47aa750124eb0152 Mon Sep 17 00:00:00 2001
From: Isira Seneviratne
Date: Fri, 4 Jul 2025 06:34:48 +0530
Subject: [PATCH 38/87] Rename .java to .kt
---
.../newpipe/player/playqueue/{PlayQueue.java => PlayQueue.kt} | 0
.../player/playqueue/{PlayQueueItem.java => PlayQueueItem.kt} | 0
2 files changed, 0 insertions(+), 0 deletions(-)
rename app/src/main/java/org/schabi/newpipe/player/playqueue/{PlayQueue.java => PlayQueue.kt} (100%)
rename app/src/main/java/org/schabi/newpipe/player/playqueue/{PlayQueueItem.java => PlayQueueItem.kt} (100%)
diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt
similarity index 100%
rename from app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java
rename to app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt
diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt
similarity index 100%
rename from app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java
rename to app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt
From 31f8dd05a7628b1e60a034c20dca4eb7e516005f Mon Sep 17 00:00:00 2001
From: Isira Seneviratne
Date: Fri, 4 Jul 2025 06:34:49 +0530
Subject: [PATCH 39/87] Convert play queue classes to Kotlin
---
.../org/schabi/newpipe/player/Player.java | 6 +-
.../player/playback/MediaSourceManager.java | 2 +-
.../newpipe/player/playqueue/PlayQueue.kt | 563 ++++++++----------
.../newpipe/player/playqueue/PlayQueueItem.kt | 202 ++-----
4 files changed, 319 insertions(+), 454 deletions(-)
diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java
index d3e3ff1df..094032a06 100644
--- a/app/src/main/java/org/schabi/newpipe/player/Player.java
+++ b/app/src/main/java/org/schabi/newpipe/player/Player.java
@@ -397,7 +397,7 @@ public final class Player implements PlaybackListener, Listener {
&& newQueue.size() == 1 && newQueue.getItem() != null
&& playQueue != null && playQueue.size() == 1 && playQueue.getItem() != null
&& newQueue.getItem().getUrl().equals(playQueue.getItem().getUrl())
- && newQueue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) {
+ && newQueue.getItem().getRecoveryPosition() != Long.MIN_VALUE) {
// Player can have state = IDLE when playback is stopped or failed
// and we should retry in this case
if (simpleExoPlayer.getPlaybackState()
@@ -425,7 +425,7 @@ public final class Player implements PlaybackListener, Listener {
&& !samePlayQueue
&& !newQueue.isEmpty()
&& newQueue.getItem() != null
- && newQueue.getItem().getRecoveryPosition() == PlayQueueItem.RECOVERY_UNSET) {
+ && newQueue.getItem().getRecoveryPosition() == Long.MIN_VALUE) {
databaseUpdateDisposable.add(recordManager.loadStreamState(newQueue.getItem())
.observeOn(AndroidSchedulers.mainThread())
// Do not place initPlayback() in doFinally() because
@@ -1588,7 +1588,7 @@ public final class Player implements PlaybackListener, Listener {
}
// sync the player index with the queue index, and seek to the correct position
- if (item.getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) {
+ if (item.getRecoveryPosition() != Long.MIN_VALUE) {
simpleExoPlayer.seekTo(playQueueIndex, item.getRecoveryPosition());
playQueue.unsetRecovery(playQueueIndex);
} else {
diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java
index 88d7145bc..9092906fa 100644
--- a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java
+++ b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java
@@ -38,9 +38,9 @@ import io.reactivex.rxjava3.internal.subscriptions.EmptySubscription;
import io.reactivex.rxjava3.schedulers.Schedulers;
import io.reactivex.rxjava3.subjects.PublishSubject;
+import static org.schabi.newpipe.BuildConfig.DEBUG;
import static org.schabi.newpipe.player.mediasource.FailedMediaSource.MediaSourceResolutionException;
import static org.schabi.newpipe.player.mediasource.FailedMediaSource.StreamInfoLoadException;
-import static org.schabi.newpipe.player.playqueue.PlayQueue.DEBUG;
import static org.schabi.newpipe.util.ServiceHelper.getCacheExpirationMillis;
public class MediaSourceManager {
diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt
index a474b624b..1ae7e5cdb 100644
--- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt
+++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt
@@ -1,189 +1,173 @@
-package org.schabi.newpipe.player.playqueue;
+package org.schabi.newpipe.player.playqueue
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import org.schabi.newpipe.MainActivity;
-import org.schabi.newpipe.player.playqueue.events.AppendEvent;
-import org.schabi.newpipe.player.playqueue.events.ErrorEvent;
-import org.schabi.newpipe.player.playqueue.events.InitEvent;
-import org.schabi.newpipe.player.playqueue.events.MoveEvent;
-import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent;
-import org.schabi.newpipe.player.playqueue.events.RecoveryEvent;
-import org.schabi.newpipe.player.playqueue.events.RemoveEvent;
-import org.schabi.newpipe.player.playqueue.events.ReorderEvent;
-import org.schabi.newpipe.player.playqueue.events.SelectEvent;
-
-import java.io.Serializable;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.concurrent.atomic.AtomicInteger;
-
-import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
-import io.reactivex.rxjava3.core.BackpressureStrategy;
-import io.reactivex.rxjava3.core.Flowable;
-import io.reactivex.rxjava3.subjects.BehaviorSubject;
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
+import io.reactivex.rxjava3.core.BackpressureStrategy
+import io.reactivex.rxjava3.core.Flowable
+import io.reactivex.rxjava3.subjects.BehaviorSubject
+import org.schabi.newpipe.player.playqueue.events.AppendEvent
+import org.schabi.newpipe.player.playqueue.events.ErrorEvent
+import org.schabi.newpipe.player.playqueue.events.InitEvent
+import org.schabi.newpipe.player.playqueue.events.MoveEvent
+import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent
+import org.schabi.newpipe.player.playqueue.events.RecoveryEvent
+import org.schabi.newpipe.player.playqueue.events.RemoveEvent
+import org.schabi.newpipe.player.playqueue.events.ReorderEvent
+import org.schabi.newpipe.player.playqueue.events.SelectEvent
+import java.io.Serializable
+import java.util.Collections
+import java.util.concurrent.atomic.AtomicInteger
/**
* PlayQueue is responsible for keeping track of a list of streams and the index of
* the stream that should be currently playing.
- *
+ *
* This class contains basic manipulation of a playlist while also functions as a
* message bus, providing all listeners with new updates to the play queue.
- *
- *
+ *
* This class can be serialized for passing intents, but in order to start the
* message bus, it must be initialized.
- *
*/
-public abstract class PlayQueue implements Serializable {
- public static final boolean DEBUG = MainActivity.DEBUG;
- @NonNull
- private final AtomicInteger queueIndex;
- private final List history = new ArrayList<>();
+abstract class PlayQueue internal constructor(
+ index: Int,
+ startWith: List,
+) : Serializable {
+ private val queueIndex = AtomicInteger(index)
+ private val history = mutableListOf()
+ private var backup = mutableListOf()
+ private var streams = startWith.toMutableList()
- private List backup;
- private List streams;
+ @Transient
+ private var eventBroadcast: BehaviorSubject? = null
- private transient BehaviorSubject eventBroadcast;
- private transient Flowable broadcastReceiver;
- private transient boolean disposed = false;
+ /**
+ * Returns the play queue's update broadcast.
+ * May be null if the play queue message bus is not initialized.
+ *
+ * @return the play queue's update broadcast
+ */
+ @Transient
+ var broadcastReceiver: Flowable? = null
+ private set
- PlayQueue(final int index, final List startWith) {
- streams = new ArrayList<>(startWith);
+ @Transient
+ var isDisposed: Boolean = false
+ private set
- if (streams.size() > index) {
- history.add(streams.get(index));
+ init {
+ if (streams.size > index) {
+ history.add(streams[index])
}
-
- queueIndex = new AtomicInteger(index);
}
/*//////////////////////////////////////////////////////////////////////////
// Playlist actions
- //////////////////////////////////////////////////////////////////////////*/
+ ////////////////////////////////////////////////////////////////////////// */
/**
* Initializes the play queue message buses.
- *
+ *
* Also starts a self reporter for logging if debug mode is enabled.
- *
*/
- public void init() {
- eventBroadcast = BehaviorSubject.create();
+ fun init() {
+ eventBroadcast = BehaviorSubject.create()
- broadcastReceiver = eventBroadcast.toFlowable(BackpressureStrategy.BUFFER)
+ broadcastReceiver =
+ eventBroadcast!!
+ .toFlowable(BackpressureStrategy.BUFFER)
.observeOn(AndroidSchedulers.mainThread())
- .startWithItem(new InitEvent());
+ .startWithItem(InitEvent())
}
/**
* Dispose the play queue by stopping all message buses.
*/
- public void dispose() {
- if (eventBroadcast != null) {
- eventBroadcast.onComplete();
- }
-
- eventBroadcast = null;
- broadcastReceiver = null;
- disposed = true;
+ open fun dispose() {
+ eventBroadcast?.onComplete()
+ eventBroadcast = null
+ broadcastReceiver = null
+ this.isDisposed = true
}
/**
* Checks if the queue is complete.
- *
+ *
* A queue is complete if it has loaded all items in an external playlist
* single stream or local queues are always complete.
- *
*
* @return whether the queue is complete
*/
- public abstract boolean isComplete();
+ abstract val isComplete: Boolean
/**
* Load partial queue in the background, does nothing if the queue is complete.
*/
- public abstract void fetch();
+ abstract fun fetch()
/*//////////////////////////////////////////////////////////////////////////
// Readonly ops
- //////////////////////////////////////////////////////////////////////////*/
-
- /**
- * @return the current index that should be played
- */
- public int getIndex() {
- return queueIndex.get();
- }
-
- /**
- * Changes the current playing index to a new index.
- *
- * This method is guarded using in a circular manner for index exceeding the play queue size.
- *
- *
- * Will emit a {@link SelectEvent} if the index is not the current playing index.
- *
- *
- * @param index the index to be set
- */
- public synchronized void setIndex(final int index) {
- final int oldIndex = getIndex();
-
- final int newIndex;
-
- if (index < 0) {
- newIndex = 0;
- } else if (index < streams.size()) {
- // Regular assignment for index in bounds
- newIndex = index;
- } else if (streams.isEmpty()) {
- // Out of bounds from here on
- // Need to check if stream is empty to prevent arithmetic error and negative index
- newIndex = 0;
- } else if (isComplete()) {
- // Circular indexing
- newIndex = index % streams.size();
- } else {
- // Index of last element
- newIndex = streams.size() - 1;
- }
-
- queueIndex.set(newIndex);
-
- if (oldIndex != newIndex) {
- history.add(streams.get(newIndex));
- }
-
- /*
- TODO: Documentation states that a SelectEvent will only be emitted if the new index is...
- different from the old one but this is emitted regardless? Not sure what this what it does
- exactly so I won't touch it
+ ////////////////////////////////////////////////////////////////////////// */
+ @set:Synchronized
+ var index: Int = 0
+ /**
+ * @return the current index that should be played
*/
- broadcast(new SelectEvent(oldIndex, newIndex));
- }
+ get() = queueIndex.get()
+
+ /**
+ * Changes the current playing index to a new index.
+ *
+ * This method is guarded using in a circular manner for index exceeding the play queue size.
+ *
+ * Will emit a [SelectEvent] if the index is not the current playing index.
+ *
+ * @param index the index to be set
+ */
+ set(index) {
+ val oldIndex = field
+
+ val newIndex: Int
+
+ if (index < 0) {
+ newIndex = 0
+ } else if (index < streams.size) {
+ // Regular assignment for index in bounds
+ newIndex = index
+ } else if (streams.isEmpty()) {
+ // Out of bounds from here on
+ // Need to check if stream is empty to prevent arithmetic error and negative index
+ newIndex = 0
+ } else if (this.isComplete) {
+ // Circular indexing
+ newIndex = index % streams.size
+ } else {
+ // Index of last element
+ newIndex = streams.size - 1
+ }
+
+ queueIndex.set(newIndex)
+
+ if (oldIndex != newIndex) {
+ history.add(streams[newIndex])
+ }
+
+ /*
+ TODO: Documentation states that a SelectEvent will only be emitted if the new index is...
+ different from the old one but this is emitted regardless? Not sure what this what it does
+ exactly so I won't touch it
+ */
+ broadcast(SelectEvent(oldIndex, newIndex))
+ }
/**
* @return the current item that should be played, or null if the queue is empty
*/
- @Nullable
- public PlayQueueItem getItem() {
- return getItem(getIndex());
- }
+ val item get() = getItem(this.index)
/**
* @param index the index of the item to return
* @return the item at the given index, or null if the index is out of bounds
*/
- @Nullable
- public PlayQueueItem getItem(final int index) {
- if (index < 0 || index >= streams.size()) {
- return null;
- }
- return streams.get(index);
- }
+ fun getItem(index: Int) = streams.getOrNull(index)
/**
* Returns the index of the given item using referential equality.
@@ -192,303 +176,280 @@ public abstract class PlayQueue implements Serializable {
* @param item the item to find the index of
* @return the index of the given item
*/
- public int indexOf(@NonNull final PlayQueueItem item) {
- return streams.indexOf(item);
- }
+ fun indexOf(item: PlayQueueItem): Int = streams.indexOf(item)
/**
* @return the current size of play queue.
*/
- public int size() {
- return streams.size();
- }
+ fun size(): Int = streams.size
/**
* Checks if the play queue is empty.
*
* @return whether the play queue is empty
*/
- public boolean isEmpty() {
- return streams.isEmpty();
- }
+ val isEmpty: Boolean
+ get() = streams.isEmpty()
/**
* Determines if the current play queue is shuffled.
*
* @return whether the play queue is shuffled
*/
- public boolean isShuffled() {
- return backup != null;
- }
+ val isShuffled: Boolean
+ get() = backup.isNotEmpty()
/**
* @return an immutable view of the play queue
*/
- @NonNull
- public List getStreams() {
- return Collections.unmodifiableList(streams);
- }
+ fun getStreams(): List = Collections.unmodifiableList(streams)
/*//////////////////////////////////////////////////////////////////////////
// Write ops
- //////////////////////////////////////////////////////////////////////////*/
-
- /**
- * Returns the play queue's update broadcast.
- * May be null if the play queue message bus is not initialized.
- *
- * @return the play queue's update broadcast
- */
- @Nullable
- public Flowable getBroadcastReceiver() {
- return broadcastReceiver;
- }
+ ////////////////////////////////////////////////////////////////////////// */
/**
* Changes the current playing index by an offset amount.
- *
- * Will emit a {@link SelectEvent} if offset is non-zero.
- *
+ *
+ * Will emit a [SelectEvent] if offset is non-zero.
*
* @param offset the offset relative to the current index
*/
- public synchronized void offsetIndex(final int offset) {
- setIndex(getIndex() + offset);
+ @Synchronized
+ fun offsetIndex(offset: Int) {
+ this.index += offset
}
/**
* Notifies that a change has occurred.
*/
- public synchronized void notifyChange() {
- broadcast(new AppendEvent(0));
+ @Synchronized
+ fun notifyChange() {
+ broadcast(AppendEvent(0))
}
/**
- * Appends the given {@link PlayQueueItem}s to the current play queue.
- *
+ * Appends the given [PlayQueueItem]s to the current play queue.
+ *
* If the play queue is shuffled, then append the items to the backup queue as is and
* append the shuffle items to the play queue.
- *
- *
- * Will emit a {@link AppendEvent} on any given context.
- *
*
- * @param items {@link PlayQueueItem}s to append
+ * Will emit a [AppendEvent] on any given context.
+ *
+ * @param items [PlayQueueItem]s to append
*/
- public synchronized void append(@NonNull final List items) {
- final List itemList = new ArrayList<>(items);
+ @Synchronized
+ fun append(items: List) {
+ val itemList = items.toMutableList()
- if (isShuffled()) {
- backup.addAll(itemList);
- Collections.shuffle(itemList);
+ if (this.isShuffled) {
+ backup.addAll(itemList)
+ itemList.shuffle()
}
- if (!streams.isEmpty() && streams.get(streams.size() - 1).isAutoQueued()
- && !itemList.get(0).isAutoQueued()) {
- streams.remove(streams.size() - 1);
+ if (!streams.isEmpty() && streams.last().isAutoQueued && !itemList[0].isAutoQueued) {
+ streams.removeAt(streams.lastIndex)
}
- streams.addAll(itemList);
+ streams.addAll(itemList)
- broadcast(new AppendEvent(itemList.size()));
+ broadcast(AppendEvent(itemList.size))
}
/**
* Removes the item at the given index from the play queue.
- *
+ *
* The current playing index will decrement if it is greater than the index being removed.
* On cases where the current playing index exceeds the playlist range, it is set to 0.
- *
- *
- * Will emit a {@link RemoveEvent} if the index is within the play queue index range.
- *
+ *
+ * Will emit a [RemoveEvent] if the index is within the play queue index range.
*
* @param index the index of the item to remove
*/
- public synchronized void remove(final int index) {
- if (index >= streams.size() || index < 0) {
- return;
+ @Synchronized
+ fun remove(index: Int) {
+ if (index >= streams.size || index < 0) {
+ return
}
- removeInternal(index);
- broadcast(new RemoveEvent(index, getIndex()));
+ removeInternal(index)
+ broadcast(RemoveEvent(index, this.index))
}
/**
* Report an exception for the item at the current index in order and skip to the next one
- *
+ *
* This is done as a separate event as the underlying manager may have
* different implementation regarding exceptions.
- *
*/
- public synchronized void error() {
- final int oldIndex = getIndex();
- queueIndex.incrementAndGet();
- if (streams.size() > queueIndex.get()) {
- history.add(streams.get(queueIndex.get()));
+ @Synchronized
+ fun error() {
+ val oldIndex = this.index
+ queueIndex.incrementAndGet()
+ if (streams.size > queueIndex.get()) {
+ history.add(streams[queueIndex.get()])
}
- broadcast(new ErrorEvent(oldIndex, getIndex()));
+ broadcast(ErrorEvent(oldIndex, this.index))
}
- private synchronized void removeInternal(final int removeIndex) {
- final int currentIndex = queueIndex.get();
- final int size = size();
+ @Synchronized
+ private fun removeInternal(removeIndex: Int) {
+ val currentIndex = queueIndex.get()
+ val size = size()
if (currentIndex > removeIndex) {
- queueIndex.decrementAndGet();
-
+ queueIndex.decrementAndGet()
} else if (currentIndex >= size) {
- queueIndex.set(currentIndex % (size - 1));
-
+ queueIndex.set(currentIndex % (size - 1))
} else if (currentIndex == removeIndex && currentIndex == size - 1) {
- queueIndex.set(0);
+ queueIndex.set(0)
}
- if (backup != null) {
- backup.remove(getItem(removeIndex));
- }
+ backup.remove(getItem(removeIndex)!!)
- history.remove(streams.remove(removeIndex));
- if (streams.size() > queueIndex.get()) {
- history.add(streams.get(queueIndex.get()));
+ history.remove(streams.removeAt(removeIndex))
+ if (streams.size > queueIndex.get()) {
+ history.add(streams[queueIndex.get()])
}
}
/**
* Moves a queue item at the source index to the target index.
- *
+ *
* If the item being moved is the currently playing, then the current playing index is set
* to that of the target.
- * If the moved item is not the currently playing and moves to an index AFTER the
+ * If the moved item is not the currently playing and moves to an index **AFTER** the
* current playing index, then the current playing index is decremented.
- * Vice versa if the an item after the currently playing is moved BEFORE.
- *
+ * Vice versa if the an item after the currently playing is moved **BEFORE**.
*
* @param source the original index of the item
* @param target the new index of the item
*/
- public synchronized void move(final int source, final int target) {
+ @Synchronized
+ fun move(
+ source: Int,
+ target: Int,
+ ) {
if (source < 0 || target < 0) {
- return;
+ return
}
- if (source >= streams.size() || target >= streams.size()) {
- return;
+ if (source >= streams.size || target >= streams.size) {
+ return
}
- final int current = getIndex();
+ val current = this.index
if (source == current) {
- queueIndex.set(target);
+ queueIndex.set(target)
} else if (source < current && target >= current) {
- queueIndex.decrementAndGet();
+ queueIndex.decrementAndGet()
} else if (source > current && target <= current) {
- queueIndex.incrementAndGet();
+ queueIndex.incrementAndGet()
}
- final PlayQueueItem playQueueItem = streams.remove(source);
- playQueueItem.setAutoQueued(false);
- streams.add(target, playQueueItem);
- broadcast(new MoveEvent(source, target));
+ val playQueueItem = streams.removeAt(source)
+ playQueueItem.isAutoQueued = false
+ streams.add(target, playQueueItem)
+ broadcast(MoveEvent(source, target))
}
/**
* Sets the recovery record of the item at the index.
- *
+ *
* Broadcasts a recovery event.
- *
*
* @param index index of the item
* @param position the recovery position
*/
- public synchronized void setRecovery(final int index, final long position) {
- if (index < 0 || index >= streams.size()) {
- return;
+ @Synchronized
+ fun setRecovery(
+ index: Int,
+ position: Long,
+ ) {
+ streams.getOrNull(index)?.let {
+ it.recoveryPosition = position
+ broadcast(RecoveryEvent(index, position))
}
-
- streams.get(index).setRecoveryPosition(position);
- broadcast(new RecoveryEvent(index, position));
}
/**
* Revoke the recovery record of the item at the index.
- *
+ *
* Broadcasts a recovery event.
- *
*
* @param index index of the item
*/
- public synchronized void unsetRecovery(final int index) {
- setRecovery(index, PlayQueueItem.RECOVERY_UNSET);
+ @Synchronized
+ fun unsetRecovery(index: Int) {
+ setRecovery(index, Long.Companion.MIN_VALUE)
}
/**
* Shuffles the current play queue
- *
+ *
* This method first backs up the existing play queue and item being played. Then a newly
* shuffled play queue will be generated along with currently playing item placed at the
* beginning of the queue. This item will also be added to the history.
- *
- *
- * Will emit a {@link ReorderEvent} if shuffled.
- *
+ *
+ * Will emit a [ReorderEvent] if shuffled.
*
* @implNote Does nothing if the queue has a size <= 2 (the currently playing video must stay on
* top, so shuffling a size-2 list does nothing)
*/
- public synchronized void shuffle() {
+ @Synchronized
+ fun shuffle() {
// Create a backup if it doesn't already exist
// Note: The backup-list has to be created at all cost (even when size <= 2).
// Otherwise it's not possible to enter shuffle-mode!
- if (backup == null) {
- backup = new ArrayList<>(streams);
+ if (backup.isEmpty()) {
+ backup = streams.toMutableList()
}
// Can't shuffle a list that's empty or only has one element
if (size() <= 2) {
- return;
+ return
}
- final int originalIndex = getIndex();
- final PlayQueueItem currentItem = getItem();
+ val originalIndex = this.index
+ val currentItem = this.item
- Collections.shuffle(streams);
+ streams.shuffle()
// Move currentItem to the head of the queue
- streams.remove(currentItem);
- streams.add(0, currentItem);
- queueIndex.set(0);
+ streams.remove(currentItem!!)
+ streams.add(0, currentItem)
+ queueIndex.set(0)
- history.add(currentItem);
+ history.add(currentItem)
- broadcast(new ReorderEvent(originalIndex, 0));
+ broadcast(ReorderEvent(originalIndex, 0))
}
/**
* Unshuffles the current play queue if a backup play queue exists.
- *
+ *
* This method undoes shuffling and index will be set to the previously playing item if found,
* otherwise, the index will reset to 0.
- *
- *
- * Will emit a {@link ReorderEvent} if a backup exists.
- *
+ *
+ * Will emit a [ReorderEvent] if a backup exists.
*/
- public synchronized void unshuffle() {
- if (backup == null) {
- return;
+ @Synchronized
+ fun unshuffle() {
+ if (backup.isEmpty()) {
+ return
}
- final int originIndex = getIndex();
- final PlayQueueItem current = getItem();
+ val originIndex = this.index
+ val current = this.item
- streams = backup;
- backup = null;
+ streams = backup
+ backup = mutableListOf()
- final int newIndex = streams.indexOf(current);
+ val newIndex = streams.indexOf(current!!)
if (newIndex != -1) {
- queueIndex.set(newIndex);
+ queueIndex.set(newIndex)
} else {
- queueIndex.set(0);
+ queueIndex.set(0)
}
- if (streams.size() > queueIndex.get()) {
- history.add(streams.get(queueIndex.get()));
+ if (streams.size > queueIndex.get()) {
+ history.add(streams[queueIndex.get()])
}
- broadcast(new ReorderEvent(originIndex, queueIndex.get()));
+ broadcast(ReorderEvent(originIndex, queueIndex.get()))
}
/**
@@ -498,18 +459,19 @@ public abstract class PlayQueue implements Serializable {
* starts playing the last item from history if it exists
*
* @return true if history is not empty and the item can be played
- * */
- public synchronized boolean previous() {
- if (history.size() <= 1) {
- return false;
+ */
+ @Synchronized
+ fun previous(): Boolean {
+ if (history.size <= 1) {
+ return false
}
- history.remove(history.size() - 1);
+ history.removeAt(history.size - 1)
- final PlayQueueItem last = history.remove(history.size() - 1);
- setIndex(indexOf(last));
+ val last = history.removeAt(history.size - 1)
+ this.index = indexOf(last)
- return true;
+ return true
}
/*
@@ -518,31 +480,18 @@ public abstract class PlayQueue implements Serializable {
* This method also gives a chance to track history of items in a queue in
* VideoDetailFragment without duplicating items from two identical queues
*/
- @Override
- public boolean equals(final Object o) {
- return o instanceof PlayQueue playQueue && streams.equals(playQueue.streams);
+ override fun equals(o: Any?): Boolean = o is PlayQueue && streams == o.streams
+
+ override fun hashCode(): Int = streams.hashCode()
+
+ fun equalStreamsAndIndex(other: PlayQueue?): Boolean {
+ return equals(other) && other!!.index == this.index // NOSONAR: other is not null
}
- @Override
- public int hashCode() {
- return streams.hashCode();
- }
-
- public boolean equalStreamsAndIndex(@Nullable final PlayQueue other) {
- return equals(other) && other.getIndex() == getIndex(); //NOSONAR: other is not null
- }
-
- public boolean isDisposed() {
- return disposed;
- }
/*//////////////////////////////////////////////////////////////////////////
- // Rx Broadcast
- //////////////////////////////////////////////////////////////////////////*/
-
- private void broadcast(@NonNull final PlayQueueEvent event) {
- if (eventBroadcast != null) {
- eventBroadcast.onNext(event);
- }
+ // Rx Broadcast
+ ////////////////////////////////////////////////////////////////////////// */
+ private fun broadcast(event: PlayQueueEvent) {
+ eventBroadcast?.onNext(event)
}
}
-
diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt
index 8f41ceb60..d6b4b0402 100644
--- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt
+++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt
@@ -1,155 +1,71 @@
-package org.schabi.newpipe.player.playqueue;
+package org.schabi.newpipe.player.playqueue
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
+import io.reactivex.rxjava3.core.Single
+import io.reactivex.rxjava3.schedulers.Schedulers
+import org.schabi.newpipe.extractor.Image
+import org.schabi.newpipe.extractor.stream.StreamInfo
+import org.schabi.newpipe.extractor.stream.StreamInfoItem
+import org.schabi.newpipe.extractor.stream.StreamType
+import org.schabi.newpipe.util.ExtractorHelper
+import java.io.Serializable
+import java.util.Objects
-import org.schabi.newpipe.extractor.Image;
-import org.schabi.newpipe.extractor.stream.StreamInfo;
-import org.schabi.newpipe.extractor.stream.StreamInfoItem;
-import org.schabi.newpipe.extractor.stream.StreamType;
-import org.schabi.newpipe.util.ExtractorHelper;
+class PlayQueueItem private constructor(
+ val title: String,
+ val url: String,
+ val serviceId: Int,
+ val duration: Long,
+ val thumbnails: List,
+ val uploader: String,
+ val uploaderUrl: String?,
+ val streamType: StreamType,
+) : Serializable {
+ //
+ // ////////////////////////////////////////////////////////////////////// */
+ // Item States, keep external access out
+ //
+ // ////////////////////////////////////////////////////////////////////// */
+ var isAutoQueued: Boolean = false
-import java.io.Serializable;
-import java.util.List;
-import java.util.Objects;
+ // package-private
+ var recoveryPosition = Long.Companion.MIN_VALUE
+ var error: Throwable? = null
+ private set
-import io.reactivex.rxjava3.core.Single;
-import io.reactivex.rxjava3.schedulers.Schedulers;
-
-public class PlayQueueItem implements Serializable {
- public static final long RECOVERY_UNSET = Long.MIN_VALUE;
- private static final String EMPTY_STRING = "";
-
- @NonNull
- private final String title;
- @NonNull
- private final String url;
- private final int serviceId;
- private final long duration;
- @NonNull
- private final List thumbnails;
- @NonNull
- private final String uploader;
- private final String uploaderUrl;
- @NonNull
- private final StreamType streamType;
-
- private boolean isAutoQueued;
-
- private long recoveryPosition;
- private Throwable error;
-
- PlayQueueItem(@NonNull final StreamInfo info) {
- this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(),
- info.getThumbnails(), info.getUploaderName(),
- info.getUploaderUrl(), info.getStreamType());
-
- if (info.getStartPosition() > 0) {
- setRecoveryPosition(info.getStartPosition() * 1000);
+ constructor(info: StreamInfo) : this(
+ info.name.orEmpty(),
+ info.url.orEmpty(),
+ info.serviceId,
+ info.duration,
+ info.thumbnails,
+ info.uploaderName.orEmpty(),
+ info.uploaderUrl,
+ info.streamType,
+ ) {
+ if (info.startPosition > 0) {
+ this.recoveryPosition = info.startPosition * 1000
}
}
- PlayQueueItem(@NonNull final StreamInfoItem item) {
- this(item.getName(), item.getUrl(), item.getServiceId(), item.getDuration(),
- item.getThumbnails(), item.getUploaderName(),
- item.getUploaderUrl(), item.getStreamType());
- }
+ constructor(item: StreamInfoItem) : this(
+ item.name.orEmpty(),
+ item.url.orEmpty(),
+ item.serviceId,
+ item.duration,
+ item.thumbnails,
+ item.uploaderName.orEmpty(),
+ item.uploaderUrl,
+ item.streamType,
+ )
- @SuppressWarnings("ParameterNumber")
- private PlayQueueItem(@Nullable final String name, @Nullable final String url,
- final int serviceId, final long duration,
- final List thumbnails, @Nullable final String uploader,
- final String uploaderUrl, @NonNull final StreamType streamType) {
- this.title = name != null ? name : EMPTY_STRING;
- this.url = url != null ? url : EMPTY_STRING;
- this.serviceId = serviceId;
- this.duration = duration;
- this.thumbnails = thumbnails;
- this.uploader = uploader != null ? uploader : EMPTY_STRING;
- this.uploaderUrl = uploaderUrl;
- this.streamType = streamType;
-
- this.recoveryPosition = RECOVERY_UNSET;
- }
-
- @NonNull
- public String getTitle() {
- return title;
- }
-
- @NonNull
- public String getUrl() {
- return url;
- }
-
- public int getServiceId() {
- return serviceId;
- }
-
- public long getDuration() {
- return duration;
- }
-
- @NonNull
- public List getThumbnails() {
- return thumbnails;
- }
-
- @NonNull
- public String getUploader() {
- return uploader;
- }
-
- public String getUploaderUrl() {
- return uploaderUrl;
- }
-
- @NonNull
- public StreamType getStreamType() {
- return streamType;
- }
-
- public long getRecoveryPosition() {
- return recoveryPosition;
- }
-
- /*package-private*/ void setRecoveryPosition(final long recoveryPosition) {
- this.recoveryPosition = recoveryPosition;
- }
-
- @Nullable
- public Throwable getError() {
- return error;
- }
-
- @NonNull
- public Single getStream() {
- return ExtractorHelper.getStreamInfo(this.serviceId, this.url, false)
+ val stream: Single
+ get() =
+ ExtractorHelper
+ .getStreamInfo(serviceId, url, false)
.subscribeOn(Schedulers.io())
- .doOnError(throwable -> error = throwable);
- }
+ .doOnError { throwable -> error = throwable }
- public boolean isAutoQueued() {
- return isAutoQueued;
- }
+ override fun equals(o: Any?) = o is PlayQueueItem && serviceId == o.serviceId && url == o.url
- ////////////////////////////////////////////////////////////////////////////
- // Item States, keep external access out
- ////////////////////////////////////////////////////////////////////////////
-
- public void setAutoQueued(final boolean autoQueued) {
- isAutoQueued = autoQueued;
- }
-
- @Override
- public boolean equals(final Object o) {
- return o instanceof PlayQueueItem item
- && serviceId == item.serviceId
- && url.equals(item.url);
- }
-
- @Override
- public int hashCode() {
- return Objects.hash(url, serviceId);
- }
+ override fun hashCode() = Objects.hash(url, serviceId)
}
From f119a368d82efea5f8d86846aecb359df1813a01 Mon Sep 17 00:00:00 2001
From: watermelon42
Date: Wed, 18 Jun 2025 17:19:54 +0300
Subject: [PATCH 40/87] Added support for importing Soundcloud likes as a new
tab before `About` in a user's channel. The likes are also retrieved in the
feed if the user is subscribed to.
---
app/build.gradle | 2 +-
.../java/org/schabi/newpipe/util/ChannelTabHelper.java | 7 +++++++
app/src/main/res/values/settings_keys.xml | 6 ++++++
app/src/main/res/values/strings.xml | 1 +
4 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/app/build.gradle b/app/build.gradle
index 5a5a2be1b..144d29113 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -214,7 +214,7 @@ dependencies {
// the corresponding commit hash, since JitPack sometimes deletes artifacts.
// If there’s already a git hash, just add more of it to the end (or remove a letter)
// to cause jitpack to regenerate the artifact.
- implementation 'com.github.TeamNewPipe.NewPipeExtractor:NewPipeExtractor:v0.24.6'
+ implementation 'com.github.TeamNewPipe.NewPipeExtractor:NewPipeExtractor:v0.24.7'
implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
/** Checkstyle **/
diff --git a/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java b/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java
index 8e8d38490..cde6e3fef 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java
@@ -24,6 +24,7 @@ public final class ChannelTabHelper {
switch (tab) {
case ChannelTabs.VIDEOS:
case ChannelTabs.TRACKS:
+ case ChannelTabs.LIKES:
case ChannelTabs.SHORTS:
case ChannelTabs.LIVESTREAMS:
return true;
@@ -62,6 +63,8 @@ public final class ChannelTabHelper {
return R.string.show_channel_tabs_playlists;
case ChannelTabs.ALBUMS:
return R.string.show_channel_tabs_albums;
+ case ChannelTabs.LIKES:
+ return R.string.show_channel_tabs_likes;
default:
return -1;
}
@@ -78,6 +81,8 @@ public final class ChannelTabHelper {
return R.string.fetch_channel_tabs_shorts;
case ChannelTabs.LIVESTREAMS:
return R.string.fetch_channel_tabs_livestreams;
+ case ChannelTabs.LIKES:
+ return R.string.fetch_channel_tabs_likes;
default:
return -1;
}
@@ -100,6 +105,8 @@ public final class ChannelTabHelper {
return R.string.channel_tab_playlists;
case ChannelTabs.ALBUMS:
return R.string.channel_tab_albums;
+ case ChannelTabs.LIKES:
+ return R.string.channel_tab_likes;
default:
return R.string.unknown_content;
}
diff --git a/app/src/main/res/values/settings_keys.xml b/app/src/main/res/values/settings_keys.xml
index 61125c47f..f98639e04 100644
--- a/app/src/main/res/values/settings_keys.xml
+++ b/app/src/main/res/values/settings_keys.xml
@@ -294,6 +294,7 @@
show_channel_tabs_channels
show_channel_tabs_playlists
show_channel_tabs_albums
+ show_channel_tabs_likes
show_channel_tabs_about
- @string/show_channel_tabs_videos
@@ -303,6 +304,7 @@
- @string/show_channel_tabs_channels
- @string/show_channel_tabs_playlists
- @string/show_channel_tabs_albums
+ - @string/show_channel_tabs_likes
- @string/show_channel_tabs_about
@@ -313,6 +315,7 @@
- @string/channel_tab_channels
- @string/channel_tab_playlists
- @string/channel_tab_albums
+ - @string/channel_tab_likes
- @string/channel_tab_about
show_search_suggestions
@@ -390,17 +393,20 @@
fetch_channel_tabs_tracks
fetch_channel_tabs_shorts
fetch_channel_tabs_livestreams
+ fetch_channel_tabs_likes
- @string/fetch_channel_tabs_videos
- @string/fetch_channel_tabs_tracks
- @string/fetch_channel_tabs_shorts
- @string/fetch_channel_tabs_livestreams
+ - @string/fetch_channel_tabs_likes
- @string/channel_tab_videos
- @string/channel_tab_tracks
- @string/channel_tab_shorts
- @string/channel_tab_livestreams
+ - @string/channel_tab_likes
import_export_data_path
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 1015dea08..4a9bfd14b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -828,6 +828,7 @@
Channels
Playlists
Albums
+ Likes
About
Channel tabs
What tabs are shown on the channel pages
From 1c0eabf75c7f154fdfff4b8e401a3c542348c3b2 Mon Sep 17 00:00:00 2001
From: watermelon42
Date: Wed, 18 Jun 2025 19:31:58 +0300
Subject: [PATCH 41/87] Updated extractor version to latest commit
---
app/build.gradle | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/build.gradle b/app/build.gradle
index 144d29113..0e59d524a 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -214,7 +214,7 @@ dependencies {
// the corresponding commit hash, since JitPack sometimes deletes artifacts.
// If there’s already a git hash, just add more of it to the end (or remove a letter)
// to cause jitpack to regenerate the artifact.
- implementation 'com.github.TeamNewPipe.NewPipeExtractor:NewPipeExtractor:v0.24.7'
+ implementation 'com.github.TeamNewPipe.NewPipeExtractor:NewPipeExtractor:71b335128e8b66ecc0cd45379dd11a0865973065'
implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
/** Checkstyle **/
From 462f0ad5c05f5a35104142181ac6e2a30c84d8d2 Mon Sep 17 00:00:00 2001
From: Profpatsch
Date: Wed, 14 May 2025 21:38:42 +0200
Subject: [PATCH 42/87] PlayerUIList: inline init block
---
.../schabi/newpipe/player/ui/PlayerUiList.kt | 24 ++++++++++---------
1 file changed, 13 insertions(+), 11 deletions(-)
diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt
index 190da81e6..ec0c85c93 100644
--- a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt
+++ b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt
@@ -3,10 +3,21 @@ package org.schabi.newpipe.player.ui
import org.schabi.newpipe.util.GuardedByMutex
import java.util.Optional
+/**
+ * Creates a [PlayerUiList] starting with the provided player uis. The provided player uis
+ * will not be prepared like those passed to [.addAndPrepare], because when
+ * the [PlayerUiList] constructor is called, the player is still not running and it
+ * wouldn't make sense to initialize uis then. Instead the player will initialize them by doing
+ * proper calls to [.call].
+ *
+ * @param initialPlayerUis the player uis this list should start with; the order will be kept
+ */
class PlayerUiList(vararg initialPlayerUis: PlayerUi) {
- private val playerUis = GuardedByMutex(mutableListOf())
+ private val playerUis = GuardedByMutex(mutableListOf(*initialPlayerUis))
/**
+ * Adds the provided player ui to the list and calls on it the initialization functions that
+ /**
* Creates a [PlayerUiList] starting with the provided player uis. The provided player uis
* will not be prepared like those passed to [.addAndPrepare], because when
* the [PlayerUiList] constructor is called, the player is still not running and it
@@ -14,16 +25,7 @@ class PlayerUiList(vararg initialPlayerUis: PlayerUi) {
* proper calls to [.call].
*
* @param initialPlayerUis the player uis this list should start with; the order will be kept
- */
- init {
- playerUis.runWithLockSync {
- lockData.addAll(listOf(*initialPlayerUis))
- }
- }
-
- /**
- * Adds the provided player ui to the list and calls on it the initialization functions that
- * apply based on the current player state. The preparation step needs to be done since when UIs
+ */* apply based on the current player state. The preparation step needs to be done since when UIs
* are removed and re-added, the player will not call e.g. initPlayer again since the exoplayer
* is already initialized, but we need to notify the newly built UI that the player is ready
* nonetheless.
From c9be4066f26d7b0c6bc606879d69c54f1bb1251e Mon Sep 17 00:00:00 2001
From: Profpatsch
Date: Wed, 14 May 2025 21:38:59 +0200
Subject: [PATCH 43/87] PlayerService: inline init block & make non-optional
---
app/src/main/java/org/schabi/newpipe/player/PlayerService.kt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt
index c335611b0..ad000a1cf 100644
--- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt
+++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt
@@ -295,7 +295,7 @@ class PlayerService : MediaBrowserServiceCompat() {
}
class LocalBinder internal constructor(playerService: PlayerService) : Binder() {
- private val playerService = WeakReference(playerService)
+ private val playerService = WeakReference(playerService)
val service: PlayerService?
get() = playerService.get()
From 86063fda6a6be0318fce7f9a33c954074b874c78 Mon Sep 17 00:00:00 2001
From: Profpatsch
Date: Tue, 13 May 2025 17:13:27 +0200
Subject: [PATCH 44/87] PlayQueueActivity: inline getServiceConnection() and
bind()
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Both are only used once, and it’s easier to see what’s going on if
they are just inlined in `onCreate`.
---
.../newpipe/player/PlayQueueActivity.java | 92 +++++++++----------
1 file changed, 42 insertions(+), 50 deletions(-)
diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java
index 9d680da4d..c5c8b20f6 100644
--- a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java
@@ -97,8 +97,48 @@ public final class PlayQueueActivity extends AppCompatActivity
getSupportActionBar().setTitle(R.string.title_activity_play_queue);
}
- serviceConnection = getServiceConnection();
- bind();
+ serviceConnection = new ServiceConnection() {
+ @Override
+ public void onServiceDisconnected(final ComponentName name) {
+ Log.d(TAG, "Player service is disconnected");
+ }
+
+ @Override
+ public void onServiceConnected(final ComponentName name, final IBinder binder) {
+ Log.d(TAG, "Player service is connected");
+
+ if (binder instanceof PlayerService.LocalBinder) {
+ @Nullable final PlayerService s =
+ ((PlayerService.LocalBinder) binder).getService();
+ if (s == null) {
+ throw new IllegalArgumentException(
+ "PlayerService.LocalBinder.getService() must never be"
+ + "null after the service connects");
+ }
+ player = s.getPlayer();
+ }
+
+ if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) {
+ unbind();
+ } else {
+ onQueueUpdate(player.getPlayQueue());
+ buildComponents();
+ if (player != null) {
+ player.setActivityListener(PlayQueueActivity.this);
+ }
+ }
+ }
+ };
+
+ // Note: this code should not really exist, and PlayerHolder should be used instead, but
+ // it will be rewritten when NewPlayer will replace the current player.
+ final Intent bindIntent = new Intent(this, PlayerService.class);
+ bindIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION);
+ final boolean success = bindService(bindIntent, serviceConnection, BIND_AUTO_CREATE);
+ if (!success) {
+ unbindService(serviceConnection);
+ }
+ serviceBound = success;
}
@Override
@@ -180,19 +220,6 @@ public final class PlayQueueActivity extends AppCompatActivity
////////////////////////////////////////////////////////////////////////////
// Service Connection
- ////////////////////////////////////////////////////////////////////////////
-
- private void bind() {
- // Note: this code should not really exist, and PlayerHolder should be used instead, but
- // it will be rewritten when NewPlayer will replace the current player.
- final Intent bindIntent = new Intent(this, PlayerService.class);
- bindIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION);
- final boolean success = bindService(bindIntent, serviceConnection, BIND_AUTO_CREATE);
- if (!success) {
- unbindService(serviceConnection);
- }
- serviceBound = success;
- }
private void unbind() {
if (serviceBound) {
@@ -212,41 +239,6 @@ public final class PlayQueueActivity extends AppCompatActivity
}
}
- private ServiceConnection getServiceConnection() {
- return new ServiceConnection() {
- @Override
- public void onServiceDisconnected(final ComponentName name) {
- Log.d(TAG, "Player service is disconnected");
- }
-
- @Override
- public void onServiceConnected(final ComponentName name, final IBinder binder) {
- Log.d(TAG, "Player service is connected");
-
- if (binder instanceof PlayerService.LocalBinder) {
- @Nullable final PlayerService s =
- ((PlayerService.LocalBinder) binder).getService();
- if (s == null) {
- throw new IllegalArgumentException(
- "PlayerService.LocalBinder.getService() must never be"
- + "null after the service connects");
- }
- player = s.getPlayer();
- }
-
- if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) {
- unbind();
- } else {
- onQueueUpdate(player.getPlayQueue());
- buildComponents();
- if (player != null) {
- player.setActivityListener(PlayQueueActivity.this);
- }
- }
- }
- };
- }
-
////////////////////////////////////////////////////////////////////////////
// Component Building
////////////////////////////////////////////////////////////////////////////
From 9b227730702179c7574da67b847e5f8c38e4d390 Mon Sep 17 00:00:00 2001
From: Profpatsch
Date: Wed, 1 Jan 2025 15:18:37 +0100
Subject: [PATCH 45/87] PlayerHolder: convert to kotlin (mechanical)
---
.../java/org/schabi/newpipe/MainActivity.java | 6 +-
.../org/schabi/newpipe/RouterActivity.java | 2 +-
.../fragments/detail/VideoDetailFragment.java | 2 +-
.../info_list/dialog/InfoItemDialog.java | 2 +-
.../newpipe/player/helper/PlayerHolder.java | 385 ------------------
.../newpipe/player/helper/PlayerHolder.kt | 384 +++++++++++++++++
.../ui/components/items/stream/StreamMenu.kt | 2 +-
.../schabi/newpipe/util/NavigationHelper.java | 8 +-
8 files changed, 395 insertions(+), 396 deletions(-)
delete mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
create mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt
diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java
index bf23d3d70..157511c9f 100644
--- a/app/src/main/java/org/schabi/newpipe/MainActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java
@@ -849,7 +849,7 @@ public class MainActivity extends AppCompatActivity {
return;
}
- if (PlayerHolder.getInstance().isPlayerOpen()) {
+ if (PlayerHolder.Companion.getInstance().isPlayerOpen()) {
// if the player is already open, no need for a broadcast receiver
openMiniPlayerIfMissing();
} else {
@@ -859,7 +859,7 @@ public class MainActivity extends AppCompatActivity {
public void onReceive(final Context context, final Intent intent) {
if (Objects.equals(intent.getAction(),
VideoDetailFragment.ACTION_PLAYER_STARTED)
- && PlayerHolder.getInstance().isPlayerOpen()) {
+ && PlayerHolder.Companion.getInstance().isPlayerOpen()) {
openMiniPlayerIfMissing();
// At this point the player is added 100%, we can unregister. Other actions
// are useless since the fragment will not be removed after that.
@@ -874,7 +874,7 @@ public class MainActivity extends AppCompatActivity {
// If the PlayerHolder is not bound yet, but the service is running, try to bind to it.
// Once the connection is established, the ACTION_PLAYER_STARTED will be sent.
- PlayerHolder.getInstance().tryBindIfNeeded(this);
+ PlayerHolder.Companion.getInstance().tryBindIfNeeded(this);
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java
index 197c965ba..27ae603c7 100644
--- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java
@@ -701,7 +701,7 @@ public class RouterActivity extends AppCompatActivity {
}
// ...the player is not running or in normal Video-mode/type
- final PlayerType playerType = PlayerHolder.getInstance().getType();
+ final PlayerType playerType = PlayerHolder.Companion.getInstance().getType();
return playerType == null || playerType == PlayerType.MAIN;
}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
index 5e0373122..ce1a50ad1 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
@@ -228,7 +228,7 @@ public final class VideoDetailFragment
@Nullable
private PlayerService playerService;
private Player player;
- private final PlayerHolder playerHolder = PlayerHolder.getInstance();
+ private final PlayerHolder playerHolder = PlayerHolder.Companion.getInstance();
/*//////////////////////////////////////////////////////////////////////////
// Service management
diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java
index dcf01e190..55d49b145 100644
--- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java
+++ b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java
@@ -252,7 +252,7 @@ public final class InfoItemDialog {
* @return the current {@link Builder} instance
*/
public Builder addEnqueueEntriesIfNeeded() {
- final PlayerHolder holder = PlayerHolder.getInstance();
+ final PlayerHolder holder = PlayerHolder.Companion.getInstance();
if (holder.isPlayQueueReady()) {
addEntry(StreamDialogDefaultEntry.ENQUEUE);
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
deleted file mode 100644
index ba8a5e0ff..000000000
--- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
+++ /dev/null
@@ -1,385 +0,0 @@
-package org.schabi.newpipe.player.helper;
-
-import android.content.ComponentName;
-import android.content.Context;
-import android.content.Intent;
-import android.content.ServiceConnection;
-import android.os.IBinder;
-import android.util.Log;
-
-import androidx.annotation.Nullable;
-import androidx.core.content.ContextCompat;
-
-import com.google.android.exoplayer2.PlaybackException;
-import com.google.android.exoplayer2.PlaybackParameters;
-
-import org.schabi.newpipe.App;
-import org.schabi.newpipe.MainActivity;
-import org.schabi.newpipe.extractor.stream.StreamInfo;
-import org.schabi.newpipe.player.PlayerService;
-import org.schabi.newpipe.player.Player;
-import org.schabi.newpipe.player.PlayerType;
-import org.schabi.newpipe.player.event.PlayerServiceEventListener;
-import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener;
-import org.schabi.newpipe.player.playqueue.PlayQueue;
-import org.schabi.newpipe.util.NavigationHelper;
-
-import java.util.Optional;
-import java.util.function.Consumer;
-
-public final class PlayerHolder {
-
- private PlayerHolder() {
- }
-
- private static PlayerHolder instance;
- public static synchronized PlayerHolder getInstance() {
- if (PlayerHolder.instance == null) {
- PlayerHolder.instance = new PlayerHolder();
- }
- return PlayerHolder.instance;
- }
-
- private static final boolean DEBUG = MainActivity.DEBUG;
- private static final String TAG = PlayerHolder.class.getSimpleName();
-
- @Nullable private PlayerServiceExtendedEventListener listener;
-
- private final PlayerServiceConnection serviceConnection = new PlayerServiceConnection();
- private boolean bound;
- @Nullable private PlayerService playerService;
-
- private Optional getPlayer() {
- return Optional.ofNullable(playerService)
- .flatMap(s -> Optional.ofNullable(s.getPlayer()));
- }
-
- private Optional getPlayQueue() {
- // player play queue might be null e.g. while player is starting
- return getPlayer().flatMap(p -> Optional.ofNullable(p.getPlayQueue()));
- }
-
- /**
- * Returns the current {@link PlayerType} of the {@link PlayerService} service,
- * otherwise `null` if no service is running.
- *
- * @return Current PlayerType
- */
- @Nullable
- public PlayerType getType() {
- return getPlayer().map(Player::getPlayerType).orElse(null);
- }
-
- public boolean isPlaying() {
- return getPlayer().map(Player::isPlaying).orElse(false);
- }
-
- public boolean isPlayerOpen() {
- return getPlayer().isPresent();
- }
-
- /**
- * Use this method to only allow the user to manipulate the play queue (e.g. by enqueueing via
- * the stream long press menu) when there actually is a play queue to manipulate.
- * @return true only if the player is open and its play queue is ready (i.e. it is not null)
- */
- public boolean isPlayQueueReady() {
- return getPlayQueue().isPresent();
- }
-
- public boolean isBound() {
- return bound;
- }
-
- public int getQueueSize() {
- return getPlayQueue().map(PlayQueue::size).orElse(0);
- }
-
- public int getQueuePosition() {
- return getPlayQueue().map(PlayQueue::getIndex).orElse(0);
- }
-
- public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) {
- listener = newListener;
-
- if (listener == null) {
- return;
- }
-
- // Force reload data from service
- if (playerService != null) {
- listener.onServiceConnected(playerService);
- startPlayerListener();
- // ^ will call listener.onPlayerConnected() down the line if there is an active player
- }
- }
-
- // helper to handle context in common place as using the same
- // context to bind/unbind a service is crucial
- private Context getCommonContext() {
- return App.getInstance();
- }
-
- /**
- * Connect to (and if needed start) the {@link PlayerService}
- * and bind {@link PlayerServiceConnection} to it.
- * If the service is already started, only set the listener.
- * @param playAfterConnect If this holder’s service was already started,
- * start playing immediately
- * @param newListener set this listener
- * */
- public void startService(final boolean playAfterConnect,
- final PlayerServiceExtendedEventListener newListener) {
- if (DEBUG) {
- Log.d(TAG, "startService() called with playAfterConnect=" + playAfterConnect);
- }
- final Context context = getCommonContext();
- setListener(newListener);
- if (bound) {
- return;
- }
- // startService() can be called concurrently and it will give a random crashes
- // and NullPointerExceptions inside the service because the service will be
- // bound twice. Prevent it with unbinding first
- unbind(context);
- final Intent intent = new Intent(context, PlayerService.class);
- intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true);
- ContextCompat.startForegroundService(context, intent);
- serviceConnection.doPlayAfterConnect(playAfterConnect);
- bind(context);
- }
-
- public void stopService() {
- if (DEBUG) {
- Log.d(TAG, "stopService() called");
- }
- if (playerService != null) {
- playerService.destroyPlayerAndStopService();
- }
- final Context context = getCommonContext();
- unbind(context);
- // destroyPlayerAndStopService() already runs the next line of code, but run it again just
- // to make sure to stop the service even if playerService is null by any chance.
- context.stopService(new Intent(context, PlayerService.class));
- }
-
- class PlayerServiceConnection implements ServiceConnection {
-
- private boolean playAfterConnect = false;
-
- /**
- * @param playAfterConnection Sets the value of `playAfterConnect` to pass to the {@link
- * PlayerServiceExtendedEventListener#onPlayerConnected(Player, boolean)} the next time it
- * is called. The value of `playAfterConnect` will be reset to false after that.
- */
- public void doPlayAfterConnect(final boolean playAfterConnection) {
- this.playAfterConnect = playAfterConnection;
- }
-
- @Override
- public void onServiceDisconnected(final ComponentName compName) {
- if (DEBUG) {
- Log.d(TAG, "Player service is disconnected");
- }
-
- final Context context = getCommonContext();
- unbind(context);
- }
-
- @Override
- public void onServiceConnected(final ComponentName compName, final IBinder service) {
- if (DEBUG) {
- Log.d(TAG, "Player service is connected");
- }
- final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service;
-
- @Nullable final PlayerService s = localBinder.getService();
- if (s == null) {
- throw new IllegalArgumentException(
- "PlayerService.LocalBinder.getService() must never be"
- + "null after the service connects");
- }
- playerService = s;
- if (listener != null) {
- listener.onServiceConnected(s);
- getPlayer().ifPresent(p -> listener.onPlayerConnected(p, playAfterConnect));
- }
- startPlayerListener();
- // ^ will call listener.onPlayerConnected() down the line if there is an active player
-
- // notify the main activity that binding the service has completed, so that it can
- // open the bottom mini-player
- NavigationHelper.sendPlayerStartedEvent(s);
- }
- }
-
- private void bind(final Context context) {
- if (DEBUG) {
- Log.d(TAG, "bind() called");
- }
- // BIND_AUTO_CREATE starts the service if it's not already running
- bound = bind(context, Context.BIND_AUTO_CREATE);
- if (!bound) {
- context.unbindService(serviceConnection);
- }
- }
-
- public void tryBindIfNeeded(final Context context) {
- if (!bound) {
- // flags=0 means the service will not be started if it does not already exist. In this
- // case the return value is not useful, as a value of "true" does not really indicate
- // that the service is going to be bound.
- bind(context, 0);
- }
- }
-
- private boolean bind(final Context context, final int flags) {
- final Intent serviceIntent = new Intent(context, PlayerService.class);
- serviceIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION);
- return context.bindService(serviceIntent, serviceConnection, flags);
- }
-
- private void unbind(final Context context) {
- if (DEBUG) {
- Log.d(TAG, "unbind() called");
- }
-
- if (bound) {
- context.unbindService(serviceConnection);
- bound = false;
- stopPlayerListener();
- playerService = null;
- if (listener != null) {
- listener.onPlayerDisconnected();
- listener.onServiceDisconnected();
- }
- }
- }
-
- private void startPlayerListener() {
- if (playerService != null) {
- // setting the player listener will take care of calling relevant callbacks if the
- // player in the service is (not) already active, also see playerStateListener below
- playerService.setPlayerListener(playerStateListener);
- }
- getPlayer().ifPresent(p -> p.setFragmentListener(internalListener));
- }
-
- private void stopPlayerListener() {
- if (playerService != null) {
- playerService.setPlayerListener(null);
- }
- getPlayer().ifPresent(p -> p.removeFragmentListener(internalListener));
- }
-
- /**
- * This listener will be held by the players created by {@link PlayerService}.
- */
- private final PlayerServiceEventListener internalListener =
- new PlayerServiceEventListener() {
- @Override
- public void onViewCreated() {
- if (listener != null) {
- listener.onViewCreated();
- }
- }
-
- @Override
- public void onFullscreenStateChanged(final boolean fullscreen) {
- if (listener != null) {
- listener.onFullscreenStateChanged(fullscreen);
- }
- }
-
- @Override
- public void onScreenRotationButtonClicked() {
- if (listener != null) {
- listener.onScreenRotationButtonClicked();
- }
- }
-
- @Override
- public void onMoreOptionsLongClicked() {
- if (listener != null) {
- listener.onMoreOptionsLongClicked();
- }
- }
-
- @Override
- public void onPlayerError(final PlaybackException error,
- final boolean isCatchableException) {
- if (listener != null) {
- listener.onPlayerError(error, isCatchableException);
- }
- }
-
- @Override
- public void hideSystemUiIfNeeded() {
- if (listener != null) {
- listener.hideSystemUiIfNeeded();
- }
- }
-
- @Override
- public void onQueueUpdate(final PlayQueue queue) {
- if (listener != null) {
- listener.onQueueUpdate(queue);
- }
- }
-
- @Override
- public void onPlaybackUpdate(final int state,
- final int repeatMode,
- final boolean shuffled,
- final PlaybackParameters parameters) {
- if (listener != null) {
- listener.onPlaybackUpdate(state, repeatMode, shuffled, parameters);
- }
- }
-
- @Override
- public void onProgressUpdate(final int currentProgress,
- final int duration,
- final int bufferPercent) {
- if (listener != null) {
- listener.onProgressUpdate(currentProgress, duration, bufferPercent);
- }
- }
-
- @Override
- public void onMetadataUpdate(final StreamInfo info, final PlayQueue queue) {
- if (listener != null) {
- listener.onMetadataUpdate(info, queue);
- }
- }
-
- @Override
- public void onServiceStopped() {
- if (listener != null) {
- listener.onServiceStopped();
- }
- unbind(getCommonContext());
- }
- };
-
- /**
- * This listener will be held by bound {@link PlayerService}s to notify of the player starting
- * or stopping. This is necessary since the service outlives the player e.g. to answer Android
- * Auto media browser queries.
- */
- private final Consumer playerStateListener = (@Nullable final Player player) -> {
- if (listener != null) {
- if (player == null) {
- // player.fragmentListener=null is already done by player.stopActivityBinding(),
- // which is called by player.destroy(), which is in turn called by PlayerService
- // before setting its player to null
- listener.onPlayerDisconnected();
- } else {
- listener.onPlayerConnected(player, serviceConnection.playAfterConnect);
- // reset the value of playAfterConnect: if it was true before, it is now "consumed"
- serviceConnection.playAfterConnect = false;
- player.setFragmentListener(internalListener);
- }
- }
- };
-}
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt
new file mode 100644
index 000000000..8cef16bec
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt
@@ -0,0 +1,384 @@
+package org.schabi.newpipe.player.helper
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.IBinder
+import android.util.Log
+import androidx.core.content.ContextCompat
+import com.google.android.exoplayer2.PlaybackException
+import com.google.android.exoplayer2.PlaybackParameters
+import org.schabi.newpipe.App
+import org.schabi.newpipe.MainActivity
+import org.schabi.newpipe.extractor.stream.StreamInfo
+import org.schabi.newpipe.player.Player
+import org.schabi.newpipe.player.PlayerService
+import org.schabi.newpipe.player.PlayerService.LocalBinder
+import org.schabi.newpipe.player.PlayerType
+import org.schabi.newpipe.player.event.PlayerServiceEventListener
+import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener
+import org.schabi.newpipe.player.playqueue.PlayQueue
+import org.schabi.newpipe.util.NavigationHelper
+import java.util.Optional
+import java.util.function.Consumer
+import java.util.function.Function
+
+class PlayerHolder private constructor() {
+ private var listener: PlayerServiceExtendedEventListener? = null
+
+ private val serviceConnection = PlayerServiceConnection()
+ var isBound: Boolean = false
+ private set
+ private var playerService: PlayerService? = null
+
+ private val player: Optional
+ get() = Optional.ofNullable(playerService)
+ .flatMap(
+ Function { s: PlayerService? ->
+ Optional.ofNullable(
+ s!!.player
+ )
+ }
+ )
+
+ private val playQueue: Optional
+ get() = // player play queue might be null e.g. while player is starting
+ this.player.flatMap(
+ Function { p: Player? ->
+ Optional.ofNullable(
+ p!!.getPlayQueue()
+ )
+ }
+ )
+
+ val type: PlayerType?
+ /**
+ * Returns the current [PlayerType] of the [PlayerService] service,
+ * otherwise `null` if no service is running.
+ *
+ * @return Current PlayerType
+ */
+ get() = this.player.map(Function { obj: Player? -> obj!!.getPlayerType() })
+ .orElse(null)
+
+ val isPlaying: Boolean
+ get() = this.player.map(Function { obj: Player? -> obj!!.isPlaying() })
+ .orElse(false)
+
+ val isPlayerOpen: Boolean
+ get() = this.player.isPresent()
+
+ val isPlayQueueReady: Boolean
+ /**
+ * Use this method to only allow the user to manipulate the play queue (e.g. by enqueueing via
+ * the stream long press menu) when there actually is a play queue to manipulate.
+ * @return true only if the player is open and its play queue is ready (i.e. it is not null)
+ */
+ get() = this.playQueue.isPresent()
+
+ val queueSize: Int
+ get() = this.playQueue.map(Function { obj: PlayQueue? -> obj!!.size() }).orElse(0)
+
+ val queuePosition: Int
+ get() = this.playQueue.map(Function { obj: PlayQueue? -> obj!!.getIndex() }).orElse(0)
+
+ fun setListener(newListener: PlayerServiceExtendedEventListener?) {
+ listener = newListener
+
+ if (listener == null) {
+ return
+ }
+
+ // Force reload data from service
+ if (playerService != null) {
+ listener!!.onServiceConnected(playerService!!)
+ startPlayerListener()
+ // ^ will call listener.onPlayerConnected() down the line if there is an active player
+ }
+ }
+
+ private val commonContext: Context
+ // helper to handle context in common place as using the same
+ get() = App.instance
+
+ /**
+ * Connect to (and if needed start) the [PlayerService]
+ * and bind [PlayerServiceConnection] to it.
+ * If the service is already started, only set the listener.
+ * @param playAfterConnect If this holder’s service was already started,
+ * start playing immediately
+ * @param newListener set this listener
+ */
+ fun startService(
+ playAfterConnect: Boolean,
+ newListener: PlayerServiceExtendedEventListener?
+ ) {
+ if (DEBUG) {
+ Log.d(TAG, "startService() called with playAfterConnect=" + playAfterConnect)
+ }
+ val context = this.commonContext
+ setListener(newListener)
+ if (this.isBound) {
+ return
+ }
+ // startService() can be called concurrently and it will give a random crashes
+ // and NullPointerExceptions inside the service because the service will be
+ // bound twice. Prevent it with unbinding first
+ unbind(context)
+ val intent = Intent(context, PlayerService::class.java)
+ intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true)
+ ContextCompat.startForegroundService(context, intent)
+ serviceConnection.doPlayAfterConnect(playAfterConnect)
+ bind(context)
+ }
+
+ fun stopService() {
+ if (DEBUG) {
+ Log.d(TAG, "stopService() called")
+ }
+ if (playerService != null) {
+ playerService!!.destroyPlayerAndStopService()
+ }
+ val context = this.commonContext
+ unbind(context)
+ // destroyPlayerAndStopService() already runs the next line of code, but run it again just
+ // to make sure to stop the service even if playerService is null by any chance.
+ context.stopService(Intent(context, PlayerService::class.java))
+ }
+
+ internal inner class PlayerServiceConnection : ServiceConnection {
+ internal var playAfterConnect = false
+
+ /**
+ * @param playAfterConnection Sets the value of [playAfterConnect] to pass to the
+ * [PlayerServiceExtendedEventListener.onPlayerConnected] the next time it
+ * is called. The value of [playAfterConnect] will be reset to false after that.
+ */
+ fun doPlayAfterConnect(playAfterConnection: Boolean) {
+ this.playAfterConnect = playAfterConnection
+ }
+
+ override fun onServiceDisconnected(compName: ComponentName?) {
+ if (DEBUG) {
+ Log.d(TAG, "Player service is disconnected")
+ }
+
+ val context: Context = this@PlayerHolder.commonContext
+ unbind(context)
+ }
+
+ override fun onServiceConnected(compName: ComponentName?, service: IBinder?) {
+ if (DEBUG) {
+ Log.d(TAG, "Player service is connected")
+ }
+ val localBinder = service as LocalBinder
+
+ val s = localBinder.service
+ requireNotNull(s) {
+ (
+ "PlayerService.LocalBinder.getService() must never be" +
+ "null after the service connects"
+ )
+ }
+ playerService = s
+ if (listener != null) {
+ listener!!.onServiceConnected(s)
+ this@PlayerHolder.player.ifPresent(
+ Consumer { p: Player? ->
+ listener!!.onPlayerConnected(
+ p!!,
+ playAfterConnect
+ )
+ }
+ )
+ }
+ startPlayerListener()
+
+ // ^ will call listener.onPlayerConnected() down the line if there is an active player
+
+ // notify the main activity that binding the service has completed, so that it can
+ // open the bottom mini-player
+ NavigationHelper.sendPlayerStartedEvent(s)
+ }
+ }
+
+ private fun bind(context: Context) {
+ if (DEBUG) {
+ Log.d(TAG, "bind() called")
+ }
+ // BIND_AUTO_CREATE starts the service if it's not already running
+ this.isBound = bind(context, Context.BIND_AUTO_CREATE)
+ if (!this.isBound) {
+ context.unbindService(serviceConnection)
+ }
+ }
+
+ fun tryBindIfNeeded(context: Context) {
+ if (!this.isBound) {
+ // flags=0 means the service will not be started if it does not already exist. In this
+ // case the return value is not useful, as a value of "true" does not really indicate
+ // that the service is going to be bound.
+ bind(context, 0)
+ }
+ }
+
+ private fun bind(context: Context, flags: Int): Boolean {
+ val serviceIntent = Intent(context, PlayerService::class.java)
+ serviceIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION)
+ return context.bindService(serviceIntent, serviceConnection, flags)
+ }
+
+ private fun unbind(context: Context) {
+ if (DEBUG) {
+ Log.d(TAG, "unbind() called")
+ }
+
+ if (this.isBound) {
+ context.unbindService(serviceConnection)
+ this.isBound = false
+ stopPlayerListener()
+ playerService = null
+ if (listener != null) {
+ listener!!.onPlayerDisconnected()
+ listener!!.onServiceDisconnected()
+ }
+ }
+ }
+
+ private fun startPlayerListener() {
+ if (playerService != null) {
+ // setting the player listener will take care of calling relevant callbacks if the
+ // player in the service is (not) already active, also see playerStateListener below
+ playerService!!.setPlayerListener(playerStateListener)
+ }
+ this.player.ifPresent(Consumer { p: Player? -> p!!.setFragmentListener(internalListener) })
+ }
+
+ private fun stopPlayerListener() {
+ if (playerService != null) {
+ playerService!!.setPlayerListener(null)
+ }
+ this.player.ifPresent(Consumer { p: Player? -> p!!.removeFragmentListener(internalListener) })
+ }
+
+ /**
+ * This listener will be held by the players created by [PlayerService].
+ */
+ private val internalListener: PlayerServiceEventListener = object : PlayerServiceEventListener {
+ override fun onViewCreated() {
+ if (listener != null) {
+ listener!!.onViewCreated()
+ }
+ }
+
+ override fun onFullscreenStateChanged(fullscreen: Boolean) {
+ if (listener != null) {
+ listener!!.onFullscreenStateChanged(fullscreen)
+ }
+ }
+
+ override fun onScreenRotationButtonClicked() {
+ if (listener != null) {
+ listener!!.onScreenRotationButtonClicked()
+ }
+ }
+
+ override fun onMoreOptionsLongClicked() {
+ if (listener != null) {
+ listener!!.onMoreOptionsLongClicked()
+ }
+ }
+
+ override fun onPlayerError(
+ error: PlaybackException?,
+ isCatchableException: Boolean
+ ) {
+ if (listener != null) {
+ listener!!.onPlayerError(error, isCatchableException)
+ }
+ }
+
+ override fun hideSystemUiIfNeeded() {
+ if (listener != null) {
+ listener!!.hideSystemUiIfNeeded()
+ }
+ }
+
+ override fun onQueueUpdate(queue: PlayQueue?) {
+ if (listener != null) {
+ listener!!.onQueueUpdate(queue)
+ }
+ }
+
+ override fun onPlaybackUpdate(
+ state: Int,
+ repeatMode: Int,
+ shuffled: Boolean,
+ parameters: PlaybackParameters?
+ ) {
+ if (listener != null) {
+ listener!!.onPlaybackUpdate(state, repeatMode, shuffled, parameters)
+ }
+ }
+
+ override fun onProgressUpdate(
+ currentProgress: Int,
+ duration: Int,
+ bufferPercent: Int
+ ) {
+ if (listener != null) {
+ listener!!.onProgressUpdate(currentProgress, duration, bufferPercent)
+ }
+ }
+
+ override fun onMetadataUpdate(info: StreamInfo?, queue: PlayQueue?) {
+ if (listener != null) {
+ listener!!.onMetadataUpdate(info, queue)
+ }
+ }
+
+ override fun onServiceStopped() {
+ if (listener != null) {
+ listener!!.onServiceStopped()
+ }
+ unbind(this@PlayerHolder.commonContext)
+ }
+ }
+
+ /**
+ * This listener will be held by bound [PlayerService]s to notify of the player starting
+ * or stopping. This is necessary since the service outlives the player e.g. to answer Android
+ * Auto media browser queries.
+ */
+ private val playerStateListener = Consumer { player: Player? ->
+ if (listener != null) {
+ if (player == null) {
+ // player.fragmentListener=null is already done by player.stopActivityBinding(),
+ // which is called by player.destroy(), which is in turn called by PlayerService
+ // before setting its player to null
+ listener!!.onPlayerDisconnected()
+ } else {
+ listener!!.onPlayerConnected(player, serviceConnection.playAfterConnect)
+ // reset the value of playAfterConnect: if it was true before, it is now "consumed"
+ serviceConnection.playAfterConnect = false;
+ player.setFragmentListener(internalListener)
+ }
+ }
+ }
+
+ companion object {
+ private var instance: PlayerHolder? = null
+
+ @Synchronized
+ fun getInstance(): PlayerHolder {
+ if (instance == null) {
+ instance = PlayerHolder()
+ }
+ return instance!!
+ }
+
+ private val DEBUG = MainActivity.DEBUG
+ private val TAG: String = PlayerHolder::class.java.getSimpleName()
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt
index 935bda85f..26d385518 100644
--- a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt
@@ -28,7 +28,7 @@ fun StreamMenu(
) {
val context = LocalContext.current
val streamViewModel = viewModel()
- val playerHolder = PlayerHolder.getInstance()
+ val playerHolder = PlayerHolder.Companion.getInstance()
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
if (playerHolder.isPlayQueueReady) {
diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
index aba27c259..9d8d3c3b2 100644
--- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
@@ -200,7 +200,7 @@ public final class NavigationHelper {
}
public static void enqueueOnPlayer(final Context context, final PlayQueue queue) {
- PlayerType playerType = PlayerHolder.getInstance().getType();
+ PlayerType playerType = PlayerHolder.Companion.getInstance().getType();
if (playerType == null) {
Log.e(TAG, "Enqueueing but no player is open; defaulting to background player");
playerType = PlayerType.AUDIO;
@@ -211,7 +211,7 @@ public final class NavigationHelper {
/* ENQUEUE NEXT */
public static void enqueueNextOnPlayer(final Context context, final PlayQueue queue) {
- PlayerType playerType = PlayerHolder.getInstance().getType();
+ PlayerType playerType = PlayerHolder.Companion.getInstance().getType();
if (playerType == null) {
Log.e(TAG, "Enqueueing next but no player is open; defaulting to background player");
playerType = PlayerType.AUDIO;
@@ -421,13 +421,13 @@ public final class NavigationHelper {
final boolean switchingPlayers) {
final boolean autoPlay;
- @Nullable final PlayerType playerType = PlayerHolder.getInstance().getType();
+ @Nullable final PlayerType playerType = PlayerHolder.Companion.getInstance().getType();
if (playerType == null) {
// no player open
autoPlay = PlayerHelper.isAutoplayAllowedByUser(context);
} else if (switchingPlayers) {
// switching player to main player
- autoPlay = PlayerHolder.getInstance().isPlaying(); // keep play/pause state
+ autoPlay = PlayerHolder.Companion.getInstance().isPlaying(); // keep play/pause state
} else if (playerType == PlayerType.MAIN) {
// opening new stream while already playing in main player
autoPlay = PlayerHelper.isAutoplayAllowedByUser(context);
From fc7daa96e99cb0fdcc6b1be345aff327ccae58d3 Mon Sep 17 00:00:00 2001
From: Profpatsch
Date: Tue, 13 May 2025 17:41:51 +0200
Subject: [PATCH 46/87] PlayerHolder: kotlinify getters
---
.../newpipe/player/helper/PlayerHolder.kt | 55 ++++++-------------
1 file changed, 18 insertions(+), 37 deletions(-)
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt
index 8cef16bec..22fff7d5d 100644
--- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt
@@ -32,25 +32,12 @@ class PlayerHolder private constructor() {
private set
private var playerService: PlayerService? = null
- private val player: Optional
- get() = Optional.ofNullable(playerService)
- .flatMap(
- Function { s: PlayerService? ->
- Optional.ofNullable(
- s!!.player
- )
- }
- )
+ private val player: Player?
+ get() = playerService?.player
- private val playQueue: Optional
+ private val playQueue: PlayQueue?
get() = // player play queue might be null e.g. while player is starting
- this.player.flatMap(
- Function { p: Player? ->
- Optional.ofNullable(
- p!!.getPlayQueue()
- )
- }
- )
+ this.player?.playQueue
val type: PlayerType?
/**
@@ -59,15 +46,13 @@ class PlayerHolder private constructor() {
*
* @return Current PlayerType
*/
- get() = this.player.map(Function { obj: Player? -> obj!!.getPlayerType() })
- .orElse(null)
+ get() = this.player?.playerType
val isPlaying: Boolean
- get() = this.player.map(Function { obj: Player? -> obj!!.isPlaying() })
- .orElse(false)
+ get() = this.player?.isPlaying == true
val isPlayerOpen: Boolean
- get() = this.player.isPresent()
+ get() = this.player != null
val isPlayQueueReady: Boolean
/**
@@ -75,13 +60,13 @@ class PlayerHolder private constructor() {
* the stream long press menu) when there actually is a play queue to manipulate.
* @return true only if the player is open and its play queue is ready (i.e. it is not null)
*/
- get() = this.playQueue.isPresent()
+ get() = this.playQueue != null
val queueSize: Int
- get() = this.playQueue.map(Function { obj: PlayQueue? -> obj!!.size() }).orElse(0)
+ get() = this.playQueue?.size() ?: 0
val queuePosition: Int
- get() = this.playQueue.map(Function { obj: PlayQueue? -> obj!!.getIndex() }).orElse(0)
+ get() = this.playQueue?.index ?: 0
fun setListener(newListener: PlayerServiceExtendedEventListener?) {
listener = newListener
@@ -182,16 +167,12 @@ class PlayerHolder private constructor() {
)
}
playerService = s
- if (listener != null) {
- listener!!.onServiceConnected(s)
- this@PlayerHolder.player.ifPresent(
- Consumer { p: Player? ->
- listener!!.onPlayerConnected(
- p!!,
- playAfterConnect
- )
- }
- )
+ val l = listener
+ if (l != null) {
+ l.onServiceConnected(s)
+ player?.let {
+ l.onPlayerConnected(it, playAfterConnect)
+ }
}
startPlayerListener()
@@ -252,14 +233,14 @@ class PlayerHolder private constructor() {
// player in the service is (not) already active, also see playerStateListener below
playerService!!.setPlayerListener(playerStateListener)
}
- this.player.ifPresent(Consumer { p: Player? -> p!!.setFragmentListener(internalListener) })
+ this.player?.setFragmentListener(internalListener)
}
private fun stopPlayerListener() {
if (playerService != null) {
playerService!!.setPlayerListener(null)
}
- this.player.ifPresent(Consumer { p: Player? -> p!!.removeFragmentListener(internalListener) })
+ this.player?.removeFragmentListener(internalListener)
}
/**
From 4fd3ddf3921d0c894b32a6860d44c6530767fb13 Mon Sep 17 00:00:00 2001
From: Profpatsch
Date: Tue, 13 May 2025 17:45:29 +0200
Subject: [PATCH 47/87] PlayerHolder: kotlinify setListener
---
.../schabi/newpipe/player/helper/PlayerHolder.kt | 14 ++++++--------
1 file changed, 6 insertions(+), 8 deletions(-)
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt
index 22fff7d5d..6e4c80cd5 100644
--- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt
@@ -71,15 +71,13 @@ class PlayerHolder private constructor() {
fun setListener(newListener: PlayerServiceExtendedEventListener?) {
listener = newListener
- if (listener == null) {
- return
- }
-
// Force reload data from service
- if (playerService != null) {
- listener!!.onServiceConnected(playerService!!)
- startPlayerListener()
- // ^ will call listener.onPlayerConnected() down the line if there is an active player
+ newListener?.let { listener ->
+ playerService?.let {
+ listener.onServiceConnected(it)
+ startPlayerListener()
+ // ^ will call listener.onPlayerConnected() down the line if there is an active player
+ }
}
}
From 86b27cf77ddcf17083075456be56ff94e924e1fd Mon Sep 17 00:00:00 2001
From: Profpatsch
Date: Tue, 13 May 2025 17:53:43 +0200
Subject: [PATCH 48/87] PlayerHolder: kotlinify optional calls
---
.../newpipe/player/helper/PlayerHolder.kt | 75 ++++++-------------
1 file changed, 22 insertions(+), 53 deletions(-)
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt
index 6e4c80cd5..b3196aeb5 100644
--- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt
@@ -20,9 +20,7 @@ import org.schabi.newpipe.player.event.PlayerServiceEventListener
import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener
import org.schabi.newpipe.player.playqueue.PlayQueue
import org.schabi.newpipe.util.NavigationHelper
-import java.util.Optional
import java.util.function.Consumer
-import java.util.function.Function
class PlayerHolder private constructor() {
private var listener: PlayerServiceExtendedEventListener? = null
@@ -120,9 +118,7 @@ class PlayerHolder private constructor() {
if (DEBUG) {
Log.d(TAG, "stopService() called")
}
- if (playerService != null) {
- playerService!!.destroyPlayerAndStopService()
- }
+ playerService?.destroyPlayerAndStopService()
val context = this.commonContext
unbind(context)
// destroyPlayerAndStopService() already runs the next line of code, but run it again just
@@ -218,26 +214,20 @@ class PlayerHolder private constructor() {
this.isBound = false
stopPlayerListener()
playerService = null
- if (listener != null) {
- listener!!.onPlayerDisconnected()
- listener!!.onServiceDisconnected()
- }
+ listener?.onPlayerDisconnected()
+ listener?.onServiceDisconnected()
}
}
private fun startPlayerListener() {
- if (playerService != null) {
- // setting the player listener will take care of calling relevant callbacks if the
- // player in the service is (not) already active, also see playerStateListener below
- playerService!!.setPlayerListener(playerStateListener)
- }
+ // setting the player listener will take care of calling relevant callbacks if the
+ // player in the service is (not) already active, also see playerStateListener below
+ playerService?.setPlayerListener(playerStateListener)
this.player?.setFragmentListener(internalListener)
}
private fun stopPlayerListener() {
- if (playerService != null) {
- playerService!!.setPlayerListener(null)
- }
+ playerService?.setPlayerListener(null)
this.player?.removeFragmentListener(internalListener)
}
@@ -246,48 +236,34 @@ class PlayerHolder private constructor() {
*/
private val internalListener: PlayerServiceEventListener = object : PlayerServiceEventListener {
override fun onViewCreated() {
- if (listener != null) {
- listener!!.onViewCreated()
- }
+ listener?.onViewCreated()
}
override fun onFullscreenStateChanged(fullscreen: Boolean) {
- if (listener != null) {
- listener!!.onFullscreenStateChanged(fullscreen)
- }
+ listener?.onFullscreenStateChanged(fullscreen)
}
override fun onScreenRotationButtonClicked() {
- if (listener != null) {
- listener!!.onScreenRotationButtonClicked()
- }
+ listener?.onScreenRotationButtonClicked()
}
override fun onMoreOptionsLongClicked() {
- if (listener != null) {
- listener!!.onMoreOptionsLongClicked()
- }
+ listener?.onMoreOptionsLongClicked()
}
override fun onPlayerError(
error: PlaybackException?,
isCatchableException: Boolean
) {
- if (listener != null) {
- listener!!.onPlayerError(error, isCatchableException)
- }
+ listener?.onPlayerError(error, isCatchableException)
}
override fun hideSystemUiIfNeeded() {
- if (listener != null) {
- listener!!.hideSystemUiIfNeeded()
- }
+ listener?.hideSystemUiIfNeeded()
}
override fun onQueueUpdate(queue: PlayQueue?) {
- if (listener != null) {
- listener!!.onQueueUpdate(queue)
- }
+ listener?.onQueueUpdate(queue)
}
override fun onPlaybackUpdate(
@@ -296,9 +272,7 @@ class PlayerHolder private constructor() {
shuffled: Boolean,
parameters: PlaybackParameters?
) {
- if (listener != null) {
- listener!!.onPlaybackUpdate(state, repeatMode, shuffled, parameters)
- }
+ listener?.onPlaybackUpdate(state, repeatMode, shuffled, parameters)
}
override fun onProgressUpdate(
@@ -306,21 +280,15 @@ class PlayerHolder private constructor() {
duration: Int,
bufferPercent: Int
) {
- if (listener != null) {
- listener!!.onProgressUpdate(currentProgress, duration, bufferPercent)
- }
+ listener?.onProgressUpdate(currentProgress, duration, bufferPercent)
}
override fun onMetadataUpdate(info: StreamInfo?, queue: PlayQueue?) {
- if (listener != null) {
- listener!!.onMetadataUpdate(info, queue)
- }
+ listener?.onMetadataUpdate(info, queue)
}
override fun onServiceStopped() {
- if (listener != null) {
- listener!!.onServiceStopped()
- }
+ listener?.onServiceStopped()
unbind(this@PlayerHolder.commonContext)
}
}
@@ -331,14 +299,15 @@ class PlayerHolder private constructor() {
* Auto media browser queries.
*/
private val playerStateListener = Consumer { player: Player? ->
- if (listener != null) {
+ val l = listener
+ if (l != null) {
if (player == null) {
// player.fragmentListener=null is already done by player.stopActivityBinding(),
// which is called by player.destroy(), which is in turn called by PlayerService
// before setting its player to null
- listener!!.onPlayerDisconnected()
+ l.onPlayerDisconnected()
} else {
- listener!!.onPlayerConnected(player, serviceConnection.playAfterConnect)
+ l.onPlayerConnected(player, serviceConnection.playAfterConnect)
// reset the value of playAfterConnect: if it was true before, it is now "consumed"
serviceConnection.playAfterConnect = false;
player.setFragmentListener(internalListener)
From bf72fd1fa5138f56875256498c14f7f651348bab Mon Sep 17 00:00:00 2001
From: Profpatsch
Date: Mon, 27 Jan 2025 15:22:56 +0100
Subject: [PATCH 49/87] VideoDetailFragment: convert to kotlin (mechanical,
failing)
Just the conversion, errors still there for easier rebasing later.
---
.../fragments/detail/VideoDetailFragment.java | 2453 --------------
.../fragments/detail/VideoDetailFragment.kt | 2808 +++++++++++++++++
2 files changed, 2808 insertions(+), 2453 deletions(-)
delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
deleted file mode 100644
index ce1a50ad1..000000000
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
+++ /dev/null
@@ -1,2453 +0,0 @@
-package org.schabi.newpipe.fragments.detail;
-
-import static android.text.TextUtils.isEmpty;
-import static org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability.COMMENTS;
-import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT;
-import static org.schabi.newpipe.ktx.ViewUtils.animate;
-import static org.schabi.newpipe.ktx.ViewUtils.animateRotation;
-import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientationLocked;
-import static org.schabi.newpipe.player.helper.PlayerHelper.isClearingQueueConfirmationRequired;
-import static org.schabi.newpipe.util.DependentPreferenceHelper.getResumePlaybackEnabled;
-import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView;
-import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams;
-import static org.schabi.newpipe.util.NavigationHelper.openPlayQueue;
-
-import android.animation.ValueAnimator;
-import android.annotation.SuppressLint;
-import android.app.Activity;
-import android.content.BroadcastReceiver;
-import android.content.Context;
-import android.content.Intent;
-import android.content.IntentFilter;
-import android.content.SharedPreferences;
-import android.content.pm.ActivityInfo;
-import android.database.ContentObserver;
-import android.graphics.Color;
-import android.graphics.Rect;
-import android.net.Uri;
-import android.os.Build;
-import android.os.Bundle;
-import android.os.Handler;
-import android.os.Looper;
-import android.provider.Settings;
-import android.util.DisplayMetrics;
-import android.util.Log;
-import android.util.TypedValue;
-import android.view.LayoutInflater;
-import android.view.MotionEvent;
-import android.view.View;
-import android.view.ViewGroup;
-import android.view.ViewTreeObserver;
-import android.view.WindowManager;
-import android.view.animation.DecelerateInterpolator;
-import android.widget.FrameLayout;
-import android.widget.RelativeLayout;
-import android.widget.Toast;
-
-import androidx.annotation.AttrRes;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.StringRes;
-import androidx.appcompat.app.AlertDialog;
-import androidx.appcompat.content.res.AppCompatResources;
-import androidx.appcompat.widget.Toolbar;
-import androidx.coordinatorlayout.widget.CoordinatorLayout;
-import androidx.core.content.ContextCompat;
-import androidx.fragment.app.Fragment;
-import androidx.preference.PreferenceManager;
-
-import com.evernote.android.state.State;
-import com.google.android.exoplayer2.PlaybackException;
-import com.google.android.exoplayer2.PlaybackParameters;
-import com.google.android.material.appbar.AppBarLayout;
-import com.google.android.material.bottomsheet.BottomSheetBehavior;
-import com.google.android.material.tabs.TabLayout;
-
-import org.schabi.newpipe.App;
-import org.schabi.newpipe.R;
-import org.schabi.newpipe.database.stream.model.StreamEntity;
-import org.schabi.newpipe.databinding.FragmentVideoDetailBinding;
-import org.schabi.newpipe.download.DownloadDialog;
-import org.schabi.newpipe.error.ErrorInfo;
-import org.schabi.newpipe.error.ErrorUtil;
-import org.schabi.newpipe.error.ReCaptchaActivity;
-import org.schabi.newpipe.error.UserAction;
-import org.schabi.newpipe.extractor.Image;
-import org.schabi.newpipe.extractor.NewPipe;
-import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
-import org.schabi.newpipe.extractor.exceptions.ExtractionException;
-import org.schabi.newpipe.extractor.stream.AudioStream;
-import org.schabi.newpipe.extractor.stream.Stream;
-import org.schabi.newpipe.extractor.stream.StreamInfo;
-import org.schabi.newpipe.extractor.stream.StreamType;
-import org.schabi.newpipe.extractor.stream.VideoStream;
-import org.schabi.newpipe.fragments.BackPressable;
-import org.schabi.newpipe.fragments.BaseStateFragment;
-import org.schabi.newpipe.fragments.EmptyFragment;
-import org.schabi.newpipe.fragments.MainFragment;
-import org.schabi.newpipe.fragments.list.comments.CommentsFragment;
-import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment;
-import org.schabi.newpipe.ktx.AnimationType;
-import org.schabi.newpipe.local.dialog.PlaylistDialog;
-import org.schabi.newpipe.local.history.HistoryRecordManager;
-import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
-import org.schabi.newpipe.player.Player;
-import org.schabi.newpipe.player.PlayerService;
-import org.schabi.newpipe.player.PlayerType;
-import org.schabi.newpipe.player.event.OnKeyDownListener;
-import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener;
-import org.schabi.newpipe.player.helper.PlayerHelper;
-import org.schabi.newpipe.player.helper.PlayerHolder;
-import org.schabi.newpipe.player.playqueue.PlayQueue;
-import org.schabi.newpipe.player.playqueue.PlayQueueItem;
-import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
-import org.schabi.newpipe.player.ui.MainPlayerUi;
-import org.schabi.newpipe.player.ui.VideoPlayerUi;
-import org.schabi.newpipe.util.Constants;
-import org.schabi.newpipe.util.DeviceUtils;
-import org.schabi.newpipe.util.ExtractorHelper;
-import org.schabi.newpipe.util.InfoCache;
-import org.schabi.newpipe.util.ListHelper;
-import org.schabi.newpipe.util.Localization;
-import org.schabi.newpipe.util.NavigationHelper;
-import org.schabi.newpipe.util.PermissionHelper;
-import org.schabi.newpipe.util.PlayButtonHelper;
-import org.schabi.newpipe.util.StreamTypeUtil;
-import org.schabi.newpipe.util.ThemeHelper;
-import org.schabi.newpipe.util.external_communication.KoreUtils;
-import org.schabi.newpipe.util.external_communication.ShareUtils;
-import org.schabi.newpipe.util.image.CoilHelper;
-
-import java.util.ArrayList;
-import java.util.Iterator;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.concurrent.TimeUnit;
-import java.util.function.Consumer;
-
-import coil3.util.CoilUtils;
-import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
-import io.reactivex.rxjava3.disposables.CompositeDisposable;
-import io.reactivex.rxjava3.disposables.Disposable;
-import io.reactivex.rxjava3.schedulers.Schedulers;
-
-public final class VideoDetailFragment
- extends BaseStateFragment
- implements BackPressable,
- PlayerServiceExtendedEventListener,
- OnKeyDownListener {
- public static final String KEY_SWITCHING_PLAYERS = "switching_players";
-
- private static final float MAX_OVERLAY_ALPHA = 0.9f;
- private static final float MAX_PLAYER_HEIGHT = 0.7f;
-
- public static final String ACTION_SHOW_MAIN_PLAYER =
- App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_SHOW_MAIN_PLAYER";
- public static final String ACTION_HIDE_MAIN_PLAYER =
- App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER";
- public static final String ACTION_PLAYER_STARTED =
- App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_PLAYER_STARTED";
- public static final String ACTION_VIDEO_FRAGMENT_RESUMED =
- App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED";
- public static final String ACTION_VIDEO_FRAGMENT_STOPPED =
- App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED";
-
- private static final String COMMENTS_TAB_TAG = "COMMENTS";
- private static final String RELATED_TAB_TAG = "NEXT VIDEO";
- private static final String DESCRIPTION_TAB_TAG = "DESCRIPTION TAB";
- private static final String EMPTY_TAB_TAG = "EMPTY TAB";
-
- // tabs
- private boolean showComments;
- private boolean showRelatedItems;
- private boolean showDescription;
- private String selectedTabTag;
- @AttrRes
- @NonNull
- final List tabIcons = new ArrayList<>();
- @StringRes
- @NonNull
- final List tabContentDescriptions = new ArrayList<>();
- private boolean tabSettingsChanged = false;
- private int lastAppBarVerticalOffset = Integer.MAX_VALUE; // prevents useless updates
-
- private final SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener =
- (sharedPreferences, key) -> {
- if (getString(R.string.show_comments_key).equals(key)) {
- showComments = sharedPreferences.getBoolean(key, true);
- tabSettingsChanged = true;
- } else if (getString(R.string.show_next_video_key).equals(key)) {
- showRelatedItems = sharedPreferences.getBoolean(key, true);
- tabSettingsChanged = true;
- } else if (getString(R.string.show_description_key).equals(key)) {
- showDescription = sharedPreferences.getBoolean(key, true);
- tabSettingsChanged = true;
- }
- };
-
- @State
- int serviceId = Constants.NO_SERVICE_ID;
- @State
- @NonNull
- String title = "";
- @State
- @Nullable
- String url = null;
- @Nullable
- private PlayQueue playQueue = null;
- @State
- int bottomSheetState = BottomSheetBehavior.STATE_EXPANDED;
- @State
- int lastStableBottomSheetState = BottomSheetBehavior.STATE_EXPANDED;
- @State
- boolean autoPlayEnabled = true;
-
- @Nullable
- private StreamInfo currentInfo = null;
- private Disposable currentWorker;
- @NonNull
- private final CompositeDisposable disposables = new CompositeDisposable();
- @Nullable
- private Disposable positionSubscriber = null;
-
- private BottomSheetBehavior bottomSheetBehavior;
- private BottomSheetBehavior.BottomSheetCallback bottomSheetCallback;
- private BroadcastReceiver broadcastReceiver;
-
- /*//////////////////////////////////////////////////////////////////////////
- // Views
- //////////////////////////////////////////////////////////////////////////*/
-
- private FragmentVideoDetailBinding binding;
-
- private TabAdapter pageAdapter;
-
- private ContentObserver settingsContentObserver;
- @Nullable
- private PlayerService playerService;
- private Player player;
- private final PlayerHolder playerHolder = PlayerHolder.Companion.getInstance();
-
- /*//////////////////////////////////////////////////////////////////////////
- // Service management
- //////////////////////////////////////////////////////////////////////////*/
- @Override
- public void onServiceConnected(@NonNull final PlayerService connectedPlayerService) {
- playerService = connectedPlayerService;
- }
-
- @Override
- public void onPlayerConnected(@NonNull final Player connectedPlayer,
- final boolean playAfterConnect) {
- player = connectedPlayer;
-
- // It will do nothing if the player is not in fullscreen mode
- hideSystemUiIfNeeded();
-
- final Optional playerUi = player.UIs().getOpt(MainPlayerUi.class);
- if (!player.videoPlayerSelected() && !playAfterConnect) {
- return;
- }
-
- if (DeviceUtils.isLandscape(requireContext())) {
- // If the video is playing but orientation changed
- // let's make the video in fullscreen again
- checkLandscape();
- } else if (playerUi.map(ui -> ui.isFullscreen() && !ui.isVerticalVideo()).orElse(false)
- // Tablet UI has orientation-independent fullscreen
- && !DeviceUtils.isTablet(activity)) {
- // Device is in portrait orientation after rotation but UI is in fullscreen.
- // Return back to non-fullscreen state
- playerUi.ifPresent(MainPlayerUi::toggleFullscreen);
- }
-
- if (playAfterConnect
- || (currentInfo != null
- && isAutoplayEnabled()
- && playerUi.isEmpty())) {
- autoPlayEnabled = true; // forcefully start playing
- openVideoPlayerAutoFullscreen();
- }
- updateOverlayPlayQueueButtonVisibility();
- }
-
- @Override
- public void onPlayerDisconnected() {
- player = null;
- // the binding could be null at this point, if the app is finishing
- if (binding != null) {
- restoreDefaultBrightness();
- }
- }
-
- @Override
- public void onServiceDisconnected() {
- playerService = null;
- }
-
-
- /*////////////////////////////////////////////////////////////////////////*/
-
- public static VideoDetailFragment getInstance(final int serviceId,
- @Nullable final String url,
- @NonNull final String name,
- @Nullable final PlayQueue queue) {
- final VideoDetailFragment instance = new VideoDetailFragment();
- instance.setInitialData(serviceId, url, name, queue);
- return instance;
- }
-
- public static VideoDetailFragment getInstanceInCollapsedState() {
- final VideoDetailFragment instance = new VideoDetailFragment();
- instance.updateBottomSheetState(BottomSheetBehavior.STATE_COLLAPSED);
- return instance;
- }
-
-
- /*//////////////////////////////////////////////////////////////////////////
- // Fragment's Lifecycle
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override
- public void onCreate(final Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
-
- final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity);
- showComments = prefs.getBoolean(getString(R.string.show_comments_key), true);
- showRelatedItems = prefs.getBoolean(getString(R.string.show_next_video_key), true);
- showDescription = prefs.getBoolean(getString(R.string.show_description_key), true);
- selectedTabTag = prefs.getString(
- getString(R.string.stream_info_selected_tab_key), COMMENTS_TAB_TAG);
- prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener);
-
- setupBroadcastReceiver();
-
- settingsContentObserver = new ContentObserver(new Handler()) {
- @Override
- public void onChange(final boolean selfChange) {
- if (activity != null && !globalScreenOrientationLocked(activity)) {
- activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
- }
- }
- };
- activity.getContentResolver().registerContentObserver(
- Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false,
- settingsContentObserver);
- }
-
- @Override
- public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container,
- final Bundle savedInstanceState) {
- binding = FragmentVideoDetailBinding.inflate(inflater, container, false);
- return binding.getRoot();
- }
-
- @Override
- public void onPause() {
- super.onPause();
- if (currentWorker != null) {
- currentWorker.dispose();
- }
- restoreDefaultBrightness();
- PreferenceManager.getDefaultSharedPreferences(requireContext())
- .edit()
- .putString(getString(R.string.stream_info_selected_tab_key),
- pageAdapter.getItemTitle(binding.viewPager.getCurrentItem()))
- .apply();
- }
-
- @Override
- public void onResume() {
- super.onResume();
- if (DEBUG) {
- Log.d(TAG, "onResume() called");
- }
-
- activity.sendBroadcast(new Intent(ACTION_VIDEO_FRAGMENT_RESUMED));
-
- updateOverlayPlayQueueButtonVisibility();
-
- setupBrightness();
-
- if (tabSettingsChanged) {
- tabSettingsChanged = false;
- initTabs();
- if (currentInfo != null) {
- updateTabs(currentInfo);
- }
- }
-
- // Check if it was loading when the fragment was stopped/paused
- if (wasLoading.getAndSet(false) && !wasCleared()) {
- startLoading(false);
- }
- }
-
- @Override
- public void onStop() {
- super.onStop();
-
- if (!activity.isChangingConfigurations()) {
- activity.sendBroadcast(new Intent(ACTION_VIDEO_FRAGMENT_STOPPED));
- }
- }
-
- @Override
- public void onDestroy() {
- super.onDestroy();
-
- // Stop the service when user leaves the app with double back press
- // if video player is selected. Otherwise unbind
- if (activity.isFinishing() && isPlayerAvailable() && player.videoPlayerSelected()) {
- playerHolder.stopService();
- } else {
- playerHolder.setListener(null);
- }
-
- PreferenceManager.getDefaultSharedPreferences(activity)
- .unregisterOnSharedPreferenceChangeListener(preferenceChangeListener);
- activity.unregisterReceiver(broadcastReceiver);
- activity.getContentResolver().unregisterContentObserver(settingsContentObserver);
-
- if (positionSubscriber != null) {
- positionSubscriber.dispose();
- }
- if (currentWorker != null) {
- currentWorker.dispose();
- }
- disposables.clear();
- positionSubscriber = null;
- currentWorker = null;
- bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback);
-
- if (activity.isFinishing()) {
- playQueue = null;
- currentInfo = null;
- stack = new LinkedList<>();
- }
- }
-
- @Override
- public void onDestroyView() {
- super.onDestroyView();
- binding = null;
- }
-
- @Override
- public void onActivityResult(final int requestCode, final int resultCode, final Intent data) {
- super.onActivityResult(requestCode, resultCode, data);
- if (requestCode == ReCaptchaActivity.RECAPTCHA_REQUEST) {
- if (resultCode == Activity.RESULT_OK) {
- NavigationHelper.openVideoDetailFragment(requireContext(), getFM(),
- serviceId, url, title, null, false);
- } else {
- Log.e(TAG, "ReCaptcha failed");
- }
- } else {
- Log.e(TAG, "Request code from activity not supported [" + requestCode + "]");
- }
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // OnClick
- //////////////////////////////////////////////////////////////////////////*/
-
- private void setOnClickListeners() {
- binding.detailTitleRootLayout.setOnClickListener(v -> toggleTitleAndSecondaryControls());
- binding.detailUploaderRootLayout.setOnClickListener(makeOnClickListener(info -> {
- if (isEmpty(info.getSubChannelUrl())) {
- if (!isEmpty(info.getUploaderUrl())) {
- openChannel(info.getUploaderUrl(), info.getUploaderName());
- }
-
- if (DEBUG) {
- Log.i(TAG, "Can't open sub-channel because we got no channel URL");
- }
- } else {
- openChannel(info.getSubChannelUrl(), info.getSubChannelName());
- }
- }));
- binding.detailThumbnailRootLayout.setOnClickListener(v -> {
- autoPlayEnabled = true; // forcefully start playing
- // FIXME Workaround #7427
- if (isPlayerAvailable()) {
- player.setRecovery();
- }
- openVideoPlayerAutoFullscreen();
- });
-
- binding.detailControlsBackground.setOnClickListener(v -> openBackgroundPlayer(false));
- binding.detailControlsPopup.setOnClickListener(v -> openPopupPlayer(false));
- binding.detailControlsPlaylistAppend.setOnClickListener(makeOnClickListener(info -> {
- if (getFM() != null && currentInfo != null) {
- final Fragment fragment = getParentFragmentManager().
- findFragmentById(R.id.fragment_holder);
-
- // commit previous pending changes to database
- if (fragment instanceof LocalPlaylistFragment) {
- ((LocalPlaylistFragment) fragment).saveImmediate();
- } else if (fragment instanceof MainFragment) {
- ((MainFragment) fragment).commitPlaylistTabs();
- }
-
- disposables.add(PlaylistDialog.createCorrespondingDialog(requireContext(),
- List.of(new StreamEntity(info)),
- dialog -> dialog.show(getParentFragmentManager(), TAG)));
- }
- }));
- binding.detailControlsDownload.setOnClickListener(v -> {
- if (PermissionHelper.checkStoragePermissions(activity,
- PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) {
- openDownloadDialog();
- }
- });
- binding.detailControlsShare.setOnClickListener(makeOnClickListener(info ->
- ShareUtils.shareText(requireContext(), info.getName(), info.getUrl(),
- info.getThumbnails())));
- binding.detailControlsOpenInBrowser.setOnClickListener(makeOnClickListener(info ->
- ShareUtils.openUrlInBrowser(requireContext(), info.getUrl())));
- binding.detailControlsPlayWithKodi.setOnClickListener(makeOnClickListener(info ->
- KoreUtils.playWithKore(requireContext(), Uri.parse(info.getUrl()))));
- if (DEBUG) {
- binding.detailControlsCrashThePlayer.setOnClickListener(v ->
- VideoDetailPlayerCrasher.onCrashThePlayer(requireContext(), player));
- }
-
- final View.OnClickListener overlayListener = v -> bottomSheetBehavior
- .setState(BottomSheetBehavior.STATE_EXPANDED);
- binding.overlayThumbnail.setOnClickListener(overlayListener);
- binding.overlayMetadataLayout.setOnClickListener(overlayListener);
- binding.overlayButtonsLayout.setOnClickListener(overlayListener);
- binding.overlayCloseButton.setOnClickListener(v -> bottomSheetBehavior
- .setState(BottomSheetBehavior.STATE_HIDDEN));
- binding.overlayPlayQueueButton.setOnClickListener(v -> openPlayQueue(requireContext()));
- binding.overlayPlayPauseButton.setOnClickListener(v -> {
- if (playerIsNotStopped()) {
- player.playPause();
- player.UIs().getOpt(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0));
- showSystemUi();
- } else {
- autoPlayEnabled = true; // forcefully start playing
- openVideoPlayer(false);
- }
-
- setOverlayPlayPauseImage(isPlayerAvailable() && player.isPlaying());
- });
- }
-
- private View.OnClickListener makeOnClickListener(final Consumer consumer) {
- return v -> {
- if (!isLoading.get() && currentInfo != null) {
- consumer.accept(currentInfo);
- }
- };
- }
-
- private void setOnLongClickListeners() {
- binding.detailTitleRootLayout.setOnLongClickListener(makeOnLongClickListener(info ->
- ShareUtils.copyToClipboard(requireContext(),
- binding.detailVideoTitleView.getText().toString())));
- binding.detailUploaderRootLayout.setOnLongClickListener(makeOnLongClickListener(info -> {
- if (isEmpty(info.getSubChannelUrl())) {
- Log.w(TAG, "Can't open parent channel because we got no parent channel URL");
- } else {
- openChannel(info.getUploaderUrl(), info.getUploaderName());
- }
- }));
-
- binding.detailControlsBackground.setOnLongClickListener(makeOnLongClickListener(info ->
- openBackgroundPlayer(true)
- ));
- binding.detailControlsPopup.setOnLongClickListener(makeOnLongClickListener(info ->
- openPopupPlayer(true)
- ));
- binding.detailControlsDownload.setOnLongClickListener(makeOnLongClickListener(info ->
- NavigationHelper.openDownloads(activity)));
-
- final View.OnLongClickListener overlayListener = makeOnLongClickListener(info ->
- openChannel(info.getUploaderUrl(), info.getUploaderName()));
- binding.overlayThumbnail.setOnLongClickListener(overlayListener);
- binding.overlayMetadataLayout.setOnLongClickListener(overlayListener);
- }
-
- private View.OnLongClickListener makeOnLongClickListener(final Consumer consumer) {
- return v -> {
- if (isLoading.get() || currentInfo == null) {
- return false;
- }
- consumer.accept(currentInfo);
- return true;
- };
- }
-
- private void openChannel(final String subChannelUrl, final String subChannelName) {
- try {
- NavigationHelper.openChannelFragment(getFM(), currentInfo.getServiceId(),
- subChannelUrl, subChannelName);
- } catch (final Exception e) {
- ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e);
- }
- }
-
- private void toggleTitleAndSecondaryControls() {
- if (binding.detailSecondaryControlPanel.getVisibility() == View.GONE) {
- binding.detailVideoTitleView.setMaxLines(10);
- animateRotation(binding.detailToggleSecondaryControlsView,
- VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 180);
- binding.detailSecondaryControlPanel.setVisibility(View.VISIBLE);
- } else {
- binding.detailVideoTitleView.setMaxLines(1);
- animateRotation(binding.detailToggleSecondaryControlsView,
- VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 0);
- binding.detailSecondaryControlPanel.setVisibility(View.GONE);
- }
- // view pager height has changed, update the tab layout
- updateTabLayoutVisibility();
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Init
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override // called from onViewCreated in {@link BaseFragment#onViewCreated}
- protected void initViews(final View rootView, final Bundle savedInstanceState) {
- super.initViews(rootView, savedInstanceState);
-
- pageAdapter = new TabAdapter(getChildFragmentManager());
- binding.viewPager.setAdapter(pageAdapter);
- binding.tabLayout.setupWithViewPager(binding.viewPager);
-
- binding.detailThumbnailRootLayout.requestFocus();
-
- binding.detailControlsPlayWithKodi.setVisibility(
- KoreUtils.shouldShowPlayWithKodi(requireContext(), serviceId)
- ? View.VISIBLE
- : View.GONE
- );
- binding.detailControlsCrashThePlayer.setVisibility(
- DEBUG && PreferenceManager.getDefaultSharedPreferences(getContext())
- .getBoolean(getString(R.string.show_crash_the_player_key), false)
- ? View.VISIBLE
- : View.GONE
- );
- accommodateForTvAndDesktopMode();
- }
-
- @Override
- @SuppressLint("ClickableViewAccessibility")
- protected void initListeners() {
- super.initListeners();
-
- setOnClickListeners();
- setOnLongClickListeners();
-
- final View.OnTouchListener controlsTouchListener = (view, motionEvent) -> {
- if (motionEvent.getAction() == MotionEvent.ACTION_DOWN
- && PlayButtonHelper.shouldShowHoldToAppendTip(activity)) {
-
- animate(binding.touchAppendDetail, true, 250, AnimationType.ALPHA, 0, () ->
- animate(binding.touchAppendDetail, false, 1500, AnimationType.ALPHA, 1000));
- }
- return false;
- };
- binding.detailControlsBackground.setOnTouchListener(controlsTouchListener);
- binding.detailControlsPopup.setOnTouchListener(controlsTouchListener);
-
- binding.appBarLayout.addOnOffsetChangedListener((layout, verticalOffset) -> {
- // prevent useless updates to tab layout visibility if nothing changed
- if (verticalOffset != lastAppBarVerticalOffset) {
- lastAppBarVerticalOffset = verticalOffset;
- // the view was scrolled
- updateTabLayoutVisibility();
- }
- });
-
- setupBottomPlayer();
- if (!playerHolder.isBound()) {
- setHeightThumbnail();
- } else {
- playerHolder.startService(false, this);
- }
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // OwnStack
- //////////////////////////////////////////////////////////////////////////*/
-
- /**
- * Stack that contains the "navigation history".
- * The peek is the current video.
- */
- private static LinkedList stack = new LinkedList<>();
-
- @Override
- public boolean onKeyDown(final int keyCode) {
- return isPlayerAvailable()
- && player.UIs().getOpt(VideoPlayerUi.class)
- .map(playerUi -> playerUi.onKeyDown(keyCode)).orElse(false);
- }
-
- @Override
- public boolean onBackPressed() {
- if (DEBUG) {
- Log.d(TAG, "onBackPressed() called");
- }
-
- // If we are in fullscreen mode just exit from it via first back press
- if (isFullscreen()) {
- if (!DeviceUtils.isTablet(activity)) {
- player.pause();
- }
- restoreDefaultOrientation();
- setAutoPlay(false);
- return true;
- }
-
- // If we have something in history of played items we replay it here
- if (isPlayerAvailable()
- && player.getPlayQueue() != null
- && player.videoPlayerSelected()
- && player.getPlayQueue().previous()) {
- return true; // no code here, as previous() was used in the if
- }
-
- // That means that we are on the start of the stack,
- if (stack.size() <= 1) {
- restoreDefaultOrientation();
- return false; // let MainActivity handle the onBack (e.g. to minimize the mini player)
- }
-
- // Remove top
- stack.pop();
- // Get stack item from the new top
- setupFromHistoryItem(Objects.requireNonNull(stack.peek()));
-
- return true;
- }
-
- private void setupFromHistoryItem(final StackItem item) {
- setAutoPlay(false);
- hideMainPlayerOnLoadingNewStream();
-
- setInitialData(item.getServiceId(), item.getUrl(),
- item.getTitle() == null ? "" : item.getTitle(), item.getPlayQueue());
- startLoading(false);
-
- // Maybe an item was deleted in background activity
- if (item.getPlayQueue().getItem() == null) {
- return;
- }
-
- final PlayQueueItem playQueueItem = item.getPlayQueue().getItem();
- // Update title, url, uploader from the last item in the stack (it's current now)
- final boolean isPlayerStopped = !isPlayerAvailable() || player.isStopped();
- if (playQueueItem != null && isPlayerStopped) {
- updateOverlayData(playQueueItem.getTitle(),
- playQueueItem.getUploader(), playQueueItem.getThumbnails());
- }
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Info loading and handling
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override
- protected void doInitialLoadLogic() {
- if (wasCleared()) {
- return;
- }
-
- if (currentInfo == null) {
- prepareAndLoadInfo();
- } else {
- prepareAndHandleInfoIfNeededAfterDelay(currentInfo, false, 50);
- }
- }
-
- public void selectAndLoadVideo(final int newServiceId,
- @Nullable final String newUrl,
- @NonNull final String newTitle,
- @Nullable final PlayQueue newQueue) {
- if (isPlayerAvailable() && newQueue != null && playQueue != null
- && playQueue.getItem() != null && !playQueue.getItem().getUrl().equals(newUrl)) {
- // Preloading can be disabled since playback is surely being replaced.
- player.disablePreloadingOfCurrentTrack();
- }
-
- setInitialData(newServiceId, newUrl, newTitle, newQueue);
- startLoading(false, true);
- }
-
- private void prepareAndHandleInfoIfNeededAfterDelay(final StreamInfo info,
- final boolean scrollToTop,
- final long delay) {
- new Handler(Looper.getMainLooper()).postDelayed(() -> {
- if (activity == null) {
- return;
- }
- // Data can already be drawn, don't spend time twice
- if (info.getName().equals(binding.detailVideoTitleView.getText().toString())) {
- return;
- }
- prepareAndHandleInfo(info, scrollToTop);
- }, delay);
- }
-
- private void prepareAndHandleInfo(final StreamInfo info, final boolean scrollToTop) {
- if (DEBUG) {
- Log.d(TAG, "prepareAndHandleInfo() called with: "
- + "info = [" + info + "], scrollToTop = [" + scrollToTop + "]");
- }
-
- showLoading();
- initTabs();
-
- if (scrollToTop) {
- scrollToTop();
- }
- handleResult(info);
- showContent();
-
- }
-
- private void prepareAndLoadInfo() {
- scrollToTop();
- startLoading(false);
- }
-
- @Override
- public void startLoading(final boolean forceLoad) {
- startLoading(forceLoad, null);
- }
-
- private void startLoading(final boolean forceLoad, final @Nullable Boolean addToBackStack) {
- super.startLoading(forceLoad);
-
- initTabs();
- currentInfo = null;
- if (currentWorker != null) {
- currentWorker.dispose();
- }
-
- runWorker(forceLoad, addToBackStack != null ? addToBackStack : stack.isEmpty());
- }
-
- private void runWorker(final boolean forceLoad, final boolean addToBackStack) {
- final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity);
- currentWorker = ExtractorHelper.getStreamInfo(serviceId, url, forceLoad)
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(result -> {
- isLoading.set(false);
- hideMainPlayerOnLoadingNewStream();
- if (result.getAgeLimit() != NO_AGE_LIMIT && !prefs.getBoolean(
- getString(R.string.show_age_restricted_content), false)) {
- hideAgeRestrictedContent();
- } else {
- handleResult(result);
- showContent();
- if (addToBackStack) {
- if (playQueue == null) {
- playQueue = new SinglePlayQueue(result);
- }
- if (stack.isEmpty() || !stack.peek().getPlayQueue().equals(playQueue)) {
- stack.push(new StackItem(serviceId, url, title, playQueue));
- }
- }
-
- if (isAutoplayEnabled()) {
- openVideoPlayerAutoFullscreen();
- }
- }
- }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_STREAM,
- url == null ? "no url" : url, serviceId)));
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Tabs
- //////////////////////////////////////////////////////////////////////////*/
-
- private void initTabs() {
- if (pageAdapter.getCount() != 0) {
- selectedTabTag = pageAdapter.getItemTitle(binding.viewPager.getCurrentItem());
- }
- pageAdapter.clearAllItems();
- tabIcons.clear();
- tabContentDescriptions.clear();
-
- if (shouldShowComments()) {
- pageAdapter.addFragment(CommentsFragment.getInstance(serviceId, url), COMMENTS_TAB_TAG);
- tabIcons.add(R.drawable.ic_comment);
- tabContentDescriptions.add(R.string.comments_tab_description);
- }
-
- if (showRelatedItems && binding.relatedItemsLayout == null) {
- // temp empty fragment. will be updated in handleResult
- pageAdapter.addFragment(EmptyFragment.newInstance(false), RELATED_TAB_TAG);
- tabIcons.add(R.drawable.ic_art_track);
- tabContentDescriptions.add(R.string.related_items_tab_description);
- }
-
- if (showDescription) {
- // temp empty fragment. will be updated in handleResult
- pageAdapter.addFragment(EmptyFragment.newInstance(false), DESCRIPTION_TAB_TAG);
- tabIcons.add(R.drawable.ic_description);
- tabContentDescriptions.add(R.string.description_tab_description);
- }
-
- if (pageAdapter.getCount() == 0) {
- pageAdapter.addFragment(EmptyFragment.newInstance(true), EMPTY_TAB_TAG);
- }
- pageAdapter.notifyDataSetUpdate();
-
- if (pageAdapter.getCount() >= 2) {
- final int position = pageAdapter.getItemPositionByTitle(selectedTabTag);
- if (position != -1) {
- binding.viewPager.setCurrentItem(position);
- }
- updateTabIconsAndContentDescriptions();
- }
- // the page adapter now contains tabs: show the tab layout
- updateTabLayoutVisibility();
- }
-
- /**
- * To be called whenever {@link #pageAdapter} is modified, since that triggers a refresh in
- * {@link FragmentVideoDetailBinding#tabLayout} resetting all tab's icons and content
- * descriptions. This reads icons from {@link #tabIcons} and content descriptions from
- * {@link #tabContentDescriptions}, which are all set in {@link #initTabs()}.
- */
- private void updateTabIconsAndContentDescriptions() {
- for (int i = 0; i < tabIcons.size(); ++i) {
- final TabLayout.Tab tab = binding.tabLayout.getTabAt(i);
- if (tab != null) {
- tab.setIcon(tabIcons.get(i));
- tab.setContentDescription(tabContentDescriptions.get(i));
- }
- }
- }
-
- private void updateTabs(@NonNull final StreamInfo info) {
- if (showRelatedItems) {
- if (binding.relatedItemsLayout == null) { // phone
- pageAdapter.updateItem(RELATED_TAB_TAG, RelatedItemsFragment.getInstance(info));
- } else { // tablet + TV
- getChildFragmentManager().beginTransaction()
- .replace(R.id.relatedItemsLayout, RelatedItemsFragment.getInstance(info))
- .commitAllowingStateLoss();
- binding.relatedItemsLayout.setVisibility(isFullscreen() ? View.GONE : View.VISIBLE);
- }
- }
-
- if (showDescription) {
- pageAdapter.updateItem(DESCRIPTION_TAB_TAG, new DescriptionFragment(info));
- }
-
- binding.viewPager.setVisibility(View.VISIBLE);
- // make sure the tab layout is visible
- updateTabLayoutVisibility();
- pageAdapter.notifyDataSetUpdate();
- updateTabIconsAndContentDescriptions();
- }
-
- private boolean shouldShowComments() {
- try {
- return showComments && NewPipe.getService(serviceId)
- .getServiceInfo()
- .getMediaCapabilities()
- .contains(COMMENTS);
- } catch (final ExtractionException e) {
- return false;
- }
- }
-
- public void updateTabLayoutVisibility() {
-
- if (binding == null) {
- //If binding is null we do not need to and should not do anything with its object(s)
- return;
- }
-
- if (pageAdapter.getCount() < 2 || binding.viewPager.getVisibility() != View.VISIBLE) {
- // hide tab layout if there is only one tab or if the view pager is also hidden
- binding.tabLayout.setVisibility(View.GONE);
- } else {
- // call `post()` to be sure `viewPager.getHitRect()`
- // is up to date and not being currently recomputed
- binding.tabLayout.post(() -> {
- final var activity = getActivity();
- if (activity != null) {
- final Rect pagerHitRect = new Rect();
- binding.viewPager.getHitRect(pagerHitRect);
-
- final int height = DeviceUtils.getWindowHeight(activity.getWindowManager());
- final int viewPagerVisibleHeight = height - pagerHitRect.top;
- // see TabLayout.DEFAULT_HEIGHT, which is equal to 48dp
- final float tabLayoutHeight = TypedValue.applyDimension(
- TypedValue.COMPLEX_UNIT_DIP, 48, getResources().getDisplayMetrics());
-
- if (viewPagerVisibleHeight > tabLayoutHeight * 2) {
- // no translation at all when viewPagerVisibleHeight > tabLayout.height * 3
- binding.tabLayout.setTranslationY(
- Math.max(0, tabLayoutHeight * 3 - viewPagerVisibleHeight));
- binding.tabLayout.setVisibility(View.VISIBLE);
- } else {
- // view pager is not visible enough
- binding.tabLayout.setVisibility(View.GONE);
- }
- }
- });
- }
- }
-
- public void scrollToTop() {
- binding.appBarLayout.setExpanded(true, true);
- // notify tab layout of scrolling
- updateTabLayoutVisibility();
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Play Utils
- //////////////////////////////////////////////////////////////////////////*/
-
- private void toggleFullscreenIfInFullscreenMode() {
- // If a user watched video inside fullscreen mode and than chose another player
- // return to non-fullscreen mode
- if (isPlayerAvailable()) {
- player.UIs().getOpt(MainPlayerUi.class).ifPresent(playerUi -> {
- if (playerUi.isFullscreen()) {
- playerUi.toggleFullscreen();
- }
- });
- }
- }
-
- private void openBackgroundPlayer(final boolean append) {
- final boolean useExternalAudioPlayer = PreferenceManager
- .getDefaultSharedPreferences(activity)
- .getBoolean(activity.getString(R.string.use_external_audio_player_key), false);
-
- toggleFullscreenIfInFullscreenMode();
-
- if (isPlayerAvailable()) {
- // FIXME Workaround #7427
- player.setRecovery();
- }
-
- if (useExternalAudioPlayer) {
- showExternalAudioPlaybackDialog();
- } else {
- openNormalBackgroundPlayer(append);
- }
- }
-
- private void openPopupPlayer(final boolean append) {
- if (!PermissionHelper.isPopupEnabledElseAsk(activity)) {
- return;
- }
-
- // See UI changes while remote playQueue changes
- if (!isPlayerAvailable()) {
- playerHolder.startService(false, this);
- } else {
- // FIXME Workaround #7427
- player.setRecovery();
- }
-
- toggleFullscreenIfInFullscreenMode();
-
- final PlayQueue queue = setupPlayQueueForIntent(append);
- if (append) { //resumePlayback: false
- NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.POPUP);
- } else {
- replaceQueueIfUserConfirms(() -> NavigationHelper
- .playOnPopupPlayer(activity, queue, true));
- }
- }
-
- /**
- * Opens the video player, in fullscreen if needed. In order to open fullscreen, the activity
- * is toggled to landscape orientation (which will then cause fullscreen mode).
- *
- * @param directlyFullscreenIfApplicable whether to open fullscreen if we are not already
- * in landscape and screen orientation is locked
- */
- public void openVideoPlayer(final boolean directlyFullscreenIfApplicable) {
- if (directlyFullscreenIfApplicable
- && !DeviceUtils.isLandscape(requireContext())
- && PlayerHelper.globalScreenOrientationLocked(requireContext())) {
- // Make sure the bottom sheet turns out expanded. When this code kicks in the bottom
- // sheet could not have fully expanded yet, and thus be in the STATE_SETTLING state.
- // When the activity is rotated, and its state is saved and then restored, the bottom
- // sheet would forget what it was doing, since even if STATE_SETTLING is restored, it
- // doesn't tell which state it was settling to, and thus the bottom sheet settles to
- // STATE_COLLAPSED. This can be solved by manually setting the state that will be
- // restored (i.e. bottomSheetState) to STATE_EXPANDED.
- updateBottomSheetState(BottomSheetBehavior.STATE_EXPANDED);
- // toggle landscape in order to open directly in fullscreen
- onScreenRotationButtonClicked();
- }
-
- if (PreferenceManager.getDefaultSharedPreferences(activity)
- .getBoolean(this.getString(R.string.use_external_video_player_key), false)) {
- showExternalVideoPlaybackDialog();
- } else {
- replaceQueueIfUserConfirms(this::openMainPlayer);
- }
- }
-
- /**
- * If the option to start directly fullscreen is enabled, calls
- * {@link #openVideoPlayer(boolean)} with {@code directlyFullscreenIfApplicable = true}, so that
- * if the user is not already in landscape and he has screen orientation locked the activity
- * rotates and fullscreen starts. Otherwise, if the option to start directly fullscreen is
- * disabled, calls {@link #openVideoPlayer(boolean)} with {@code directlyFullscreenIfApplicable
- * = false}, hence preventing it from going directly fullscreen.
- */
- public void openVideoPlayerAutoFullscreen() {
- openVideoPlayer(PlayerHelper.isStartMainPlayerFullscreenEnabled(requireContext()));
- }
-
- private void openNormalBackgroundPlayer(final boolean append) {
- // See UI changes while remote playQueue changes
- if (!isPlayerAvailable()) {
- playerHolder.startService(false, this);
- }
-
- final PlayQueue queue = setupPlayQueueForIntent(append);
- if (append) {
- NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.AUDIO);
- } else {
- replaceQueueIfUserConfirms(() -> NavigationHelper
- .playOnBackgroundPlayer(activity, queue, true));
- }
- }
-
- private void openMainPlayer() {
- if (noPlayerServiceAvailable()) {
- playerHolder.startService(autoPlayEnabled, this);
- return;
- }
- if (currentInfo == null) {
- return;
- }
-
- final PlayQueue queue = setupPlayQueueForIntent(false);
- tryAddVideoPlayerView();
-
- final Intent playerIntent = NavigationHelper.getPlayerIntent(requireContext(),
- PlayerService.class, queue, true, autoPlayEnabled);
- ContextCompat.startForegroundService(activity, playerIntent);
- }
-
- /**
- * When the video detail fragment is already showing details for a video and the user opens a
- * new one, the video detail fragment changes all of its old data to the new stream, so if there
- * is a video player currently open it should be hidden. This method does exactly that. If
- * autoplay is enabled, the underlying player is not stopped completely, since it is going to
- * be reused in a few milliseconds and the flickering would be annoying.
- */
- private void hideMainPlayerOnLoadingNewStream() {
- final var root = getRoot();
- if (noPlayerServiceAvailable() || root.isEmpty() || !player.videoPlayerSelected()) {
- return;
- }
-
- removeVideoPlayerView();
- if (isAutoplayEnabled()) {
- playerService.stopForImmediateReusing();
- root.ifPresent(view -> view.setVisibility(View.GONE));
- } else {
- playerHolder.stopService();
- }
- }
-
- private PlayQueue setupPlayQueueForIntent(final boolean append) {
- if (append) {
- return new SinglePlayQueue(currentInfo);
- }
-
- PlayQueue queue = playQueue;
- // Size can be 0 because queue removes bad stream automatically when error occurs
- if (queue == null || queue.isEmpty()) {
- queue = new SinglePlayQueue(currentInfo);
- }
-
- return queue;
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Utils
- //////////////////////////////////////////////////////////////////////////*/
-
- public void setAutoPlay(final boolean autoPlay) {
- this.autoPlayEnabled = autoPlay;
- }
-
- private void startOnExternalPlayer(@NonNull final Context context,
- @NonNull final StreamInfo info,
- @NonNull final Stream selectedStream) {
- NavigationHelper.playOnExternalPlayer(context, currentInfo.getName(),
- currentInfo.getSubChannelName(), selectedStream);
-
- final HistoryRecordManager recordManager = new HistoryRecordManager(requireContext());
- disposables.add(recordManager.onViewed(info).onErrorComplete()
- .subscribe(
- ignored -> { /* successful */ },
- error -> Log.e(TAG, "Register view failure: ", error)
- ));
- }
-
- private boolean isExternalPlayerEnabled() {
- return PreferenceManager.getDefaultSharedPreferences(requireContext())
- .getBoolean(getString(R.string.use_external_video_player_key), false);
- }
-
- // This method overrides default behaviour when setAutoPlay() is called.
- // Don't auto play if the user selected an external player or disabled it in settings
- private boolean isAutoplayEnabled() {
- return autoPlayEnabled
- && !isExternalPlayerEnabled()
- && (!isPlayerAvailable() || player.videoPlayerSelected())
- && bottomSheetState != BottomSheetBehavior.STATE_HIDDEN
- && PlayerHelper.isAutoplayAllowedByUser(requireContext());
- }
-
- private void tryAddVideoPlayerView() {
- if (isPlayerAvailable() && getView() != null) {
- // Setup the surface view height, so that it fits the video correctly; this is done also
- // here, and not only in the Handler, to avoid a choppy fullscreen rotation animation.
- setHeightThumbnail();
- }
-
- // do all the null checks in the posted lambda, too, since the player, the binding and the
- // view could be set or unset before the lambda gets executed on the next main thread cycle
- new Handler(Looper.getMainLooper()).post(() -> {
- if (!isPlayerAvailable() || getView() == null) {
- return;
- }
-
- // setup the surface view height, so that it fits the video correctly
- setHeightThumbnail();
-
- player.UIs().getOpt(MainPlayerUi.class).ifPresent(playerUi -> {
- // sometimes binding would be null here, even though getView() != null above u.u
- if (binding != null) {
- // prevent from re-adding a view multiple times
- playerUi.removeViewFromParent();
- binding.playerPlaceholder.addView(playerUi.getBinding().getRoot());
- playerUi.setupVideoSurfaceIfNeeded();
- }
- });
- });
- }
-
- private void removeVideoPlayerView() {
- makeDefaultHeightForVideoPlaceholder();
-
- if (player != null) {
- player.UIs().getOpt(VideoPlayerUi.class).ifPresent(VideoPlayerUi::removeViewFromParent);
- }
- }
-
- private void makeDefaultHeightForVideoPlaceholder() {
- if (getView() == null) {
- return;
- }
-
- binding.playerPlaceholder.getLayoutParams().height = FrameLayout.LayoutParams.MATCH_PARENT;
- binding.playerPlaceholder.requestLayout();
- }
-
- private final ViewTreeObserver.OnPreDrawListener preDrawListener =
- new ViewTreeObserver.OnPreDrawListener() {
- @Override
- public boolean onPreDraw() {
- final DisplayMetrics metrics = getResources().getDisplayMetrics();
-
- if (getView() != null) {
- final int height = (DeviceUtils.isInMultiWindow(activity)
- ? requireView()
- : activity.getWindow().getDecorView()).getHeight();
- setHeightThumbnail(height, metrics);
- getView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener);
- }
- return false;
- }
- };
-
- /**
- * Method which controls the size of thumbnail and the size of main player inside
- * a layout with thumbnail. It decides what height the player should have in both
- * screen orientations. It knows about multiWindow feature
- * and about videos with aspectRatio ZOOM (the height for them will be a bit higher,
- * {@link #MAX_PLAYER_HEIGHT})
- */
- private void setHeightThumbnail() {
- final DisplayMetrics metrics = getResources().getDisplayMetrics();
- final boolean isPortrait = metrics.heightPixels > metrics.widthPixels;
- requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener);
-
- if (isFullscreen()) {
- final int height = (DeviceUtils.isInMultiWindow(activity)
- ? requireView()
- : activity.getWindow().getDecorView()).getHeight();
- // Height is zero when the view is not yet displayed like after orientation change
- if (height != 0) {
- setHeightThumbnail(height, metrics);
- } else {
- requireView().getViewTreeObserver().addOnPreDrawListener(preDrawListener);
- }
- } else {
- final int height = (int) (isPortrait
- ? metrics.widthPixels / (16.0f / 9.0f)
- : metrics.heightPixels / 2.0f);
- setHeightThumbnail(height, metrics);
- }
- }
-
- private void setHeightThumbnail(final int newHeight, final DisplayMetrics metrics) {
- binding.detailThumbnailImageView.setLayoutParams(
- new FrameLayout.LayoutParams(
- RelativeLayout.LayoutParams.MATCH_PARENT, newHeight));
- binding.detailThumbnailImageView.setMinimumHeight(newHeight);
- if (isPlayerAvailable()) {
- final int maxHeight = (int) (metrics.heightPixels * MAX_PLAYER_HEIGHT);
- player.UIs().getOpt(VideoPlayerUi.class).ifPresent(ui ->
- ui.getBinding().surfaceView.setHeights(newHeight,
- ui.isFullscreen() ? newHeight : maxHeight));
- }
- }
-
- private void showContent() {
- binding.detailContentRootHiding.setVisibility(View.VISIBLE);
- }
-
- private void setInitialData(final int newServiceId,
- @Nullable final String newUrl,
- @NonNull final String newTitle,
- @Nullable final PlayQueue newPlayQueue) {
- this.serviceId = newServiceId;
- this.url = newUrl;
- this.title = newTitle;
- this.playQueue = newPlayQueue;
- }
-
- private void setErrorImage() {
- if (binding == null || activity == null) {
- return;
- }
-
- binding.detailThumbnailImageView.setImageDrawable(
- AppCompatResources.getDrawable(requireContext(), R.drawable.not_available_monkey));
- animate(binding.detailThumbnailImageView, false, 0, AnimationType.ALPHA,
- 0, () -> animate(binding.detailThumbnailImageView, true, 500));
- }
-
- @Override
- public void handleError() {
- super.handleError();
- setErrorImage();
-
- if (binding.relatedItemsLayout != null) { // hide related streams for tablets
- binding.relatedItemsLayout.setVisibility(View.INVISIBLE);
- }
-
- // hide comments / related streams / description tabs
- binding.viewPager.setVisibility(View.GONE);
- binding.tabLayout.setVisibility(View.GONE);
- }
-
- private void hideAgeRestrictedContent() {
- showTextError(getString(R.string.restricted_video,
- getString(R.string.show_age_restricted_content_title)));
- }
-
- private void setupBroadcastReceiver() {
- broadcastReceiver = new BroadcastReceiver() {
- @Override
- public void onReceive(final Context context, final Intent intent) {
- switch (intent.getAction()) {
- case ACTION_SHOW_MAIN_PLAYER:
- bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED);
- break;
- case ACTION_HIDE_MAIN_PLAYER:
- bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
- break;
- case ACTION_PLAYER_STARTED:
- // If the state is not hidden we don't need to show the mini player
- if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_HIDDEN) {
- bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
- }
- // Rebound to the service if it was closed via notification or mini player
- if (!playerHolder.isBound()) {
- playerHolder.startService(
- false, VideoDetailFragment.this);
- }
- break;
- }
- }
- };
- final IntentFilter intentFilter = new IntentFilter();
- intentFilter.addAction(ACTION_SHOW_MAIN_PLAYER);
- intentFilter.addAction(ACTION_HIDE_MAIN_PLAYER);
- intentFilter.addAction(ACTION_PLAYER_STARTED);
- activity.registerReceiver(broadcastReceiver, intentFilter);
- }
-
-
- /*//////////////////////////////////////////////////////////////////////////
- // Orientation listener
- //////////////////////////////////////////////////////////////////////////*/
-
- private void restoreDefaultOrientation() {
- if (isPlayerAvailable() && player.videoPlayerSelected()) {
- toggleFullscreenIfInFullscreenMode();
- }
-
- // This will show systemUI and pause the player.
- // User can tap on Play button and video will be in fullscreen mode again
- // Note for tablet: trying to avoid orientation changes since it's not easy
- // to physically rotate the tablet every time
- if (activity != null && !DeviceUtils.isTablet(activity)) {
- activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
- }
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Contract
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override
- public void showLoading() {
-
- super.showLoading();
-
- //if data is already cached, transition from VISIBLE -> INVISIBLE -> VISIBLE is not required
- if (!ExtractorHelper.isCached(serviceId, url, InfoCache.Type.STREAM)) {
- binding.detailContentRootHiding.setVisibility(View.INVISIBLE);
- }
-
- animate(binding.detailThumbnailPlayButton, false, 50);
- animate(binding.detailDurationView, false, 100);
- binding.detailPositionView.setVisibility(View.GONE);
- binding.positionView.setVisibility(View.GONE);
-
- binding.detailVideoTitleView.setText(title);
- binding.detailVideoTitleView.setMaxLines(1);
- animate(binding.detailVideoTitleView, true, 0);
-
- binding.detailToggleSecondaryControlsView.setVisibility(View.GONE);
- binding.detailTitleRootLayout.setClickable(false);
- binding.detailSecondaryControlPanel.setVisibility(View.GONE);
-
- if (binding.relatedItemsLayout != null) {
- if (showRelatedItems) {
- binding.relatedItemsLayout.setVisibility(
- isFullscreen() ? View.GONE : View.INVISIBLE);
- } else {
- binding.relatedItemsLayout.setVisibility(View.GONE);
- }
- }
-
- CoilUtils.dispose(binding.detailThumbnailImageView);
- CoilUtils.dispose(binding.detailSubChannelThumbnailView);
- CoilUtils.dispose(binding.overlayThumbnail);
- CoilUtils.dispose(binding.detailUploaderThumbnailView);
-
- binding.detailThumbnailImageView.setImageBitmap(null);
- binding.detailSubChannelThumbnailView.setImageBitmap(null);
- }
-
- @Override
- public void handleResult(@NonNull final StreamInfo info) {
- super.handleResult(info);
-
- currentInfo = info;
- setInitialData(info.getServiceId(), info.getOriginalUrl(), info.getName(), playQueue);
-
- updateTabs(info);
-
- animate(binding.detailThumbnailPlayButton, true, 200);
- binding.detailVideoTitleView.setText(title);
-
- binding.detailSubChannelThumbnailView.setVisibility(View.GONE);
-
- if (!isEmpty(info.getSubChannelName())) {
- displayBothUploaderAndSubChannel(info);
- } else {
- displayUploaderAsSubChannel(info);
- }
-
- if (info.getViewCount() >= 0) {
- if (info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) {
- binding.detailViewCountView.setText(Localization.listeningCount(activity,
- info.getViewCount()));
- } else if (info.getStreamType().equals(StreamType.LIVE_STREAM)) {
- binding.detailViewCountView.setText(Localization
- .localizeWatchingCount(activity, info.getViewCount()));
- } else {
- binding.detailViewCountView.setText(Localization
- .localizeViewCount(activity, info.getViewCount()));
- }
- binding.detailViewCountView.setVisibility(View.VISIBLE);
- } else {
- binding.detailViewCountView.setVisibility(View.GONE);
- }
-
- if (info.getDislikeCount() == -1 && info.getLikeCount() == -1) {
- binding.detailThumbsDownImgView.setVisibility(View.VISIBLE);
- binding.detailThumbsUpImgView.setVisibility(View.VISIBLE);
- binding.detailThumbsUpCountView.setVisibility(View.GONE);
- binding.detailThumbsDownCountView.setVisibility(View.GONE);
-
- binding.detailThumbsDisabledView.setVisibility(View.VISIBLE);
- } else {
- if (info.getDislikeCount() >= 0) {
- binding.detailThumbsDownCountView.setText(Localization
- .shortCount(activity, info.getDislikeCount()));
- binding.detailThumbsDownCountView.setVisibility(View.VISIBLE);
- binding.detailThumbsDownImgView.setVisibility(View.VISIBLE);
- } else {
- binding.detailThumbsDownCountView.setVisibility(View.GONE);
- binding.detailThumbsDownImgView.setVisibility(View.GONE);
- }
-
- if (info.getLikeCount() >= 0) {
- binding.detailThumbsUpCountView.setText(Localization.shortCount(activity,
- info.getLikeCount()));
- binding.detailThumbsUpCountView.setVisibility(View.VISIBLE);
- binding.detailThumbsUpImgView.setVisibility(View.VISIBLE);
- } else {
- binding.detailThumbsUpCountView.setVisibility(View.GONE);
- binding.detailThumbsUpImgView.setVisibility(View.GONE);
- }
- binding.detailThumbsDisabledView.setVisibility(View.GONE);
- }
-
- if (info.getDuration() > 0) {
- binding.detailDurationView.setText(Localization.getDurationString(info.getDuration()));
- binding.detailDurationView.setBackgroundColor(
- ContextCompat.getColor(activity, R.color.duration_background_color));
- animate(binding.detailDurationView, true, 100);
- } else if (info.getStreamType() == StreamType.LIVE_STREAM) {
- binding.detailDurationView.setText(R.string.duration_live);
- binding.detailDurationView.setBackgroundColor(
- ContextCompat.getColor(activity, R.color.live_duration_background_color));
- animate(binding.detailDurationView, true, 100);
- } else {
- binding.detailDurationView.setVisibility(View.GONE);
- }
-
- binding.detailTitleRootLayout.setClickable(true);
- binding.detailToggleSecondaryControlsView.setRotation(0);
- binding.detailToggleSecondaryControlsView.setVisibility(View.VISIBLE);
- binding.detailSecondaryControlPanel.setVisibility(View.GONE);
-
- checkUpdateProgressInfo(info);
- CoilHelper.INSTANCE.loadDetailsThumbnail(binding.detailThumbnailImageView,
- info.getThumbnails());
- showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView,
- binding.detailMetaInfoSeparator, disposables);
-
- if (!isPlayerAvailable() || player.isStopped()) {
- updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails());
- }
-
- if (!info.getErrors().isEmpty()) {
- // Bandcamp fan pages are not yet supported and thus a ContentNotAvailableException is
- // thrown. This is not an error and thus should not be shown to the user.
- for (final Throwable throwable : info.getErrors()) {
- if (throwable instanceof ContentNotSupportedException
- && "Fan pages are not supported".equals(throwable.getMessage())) {
- info.getErrors().remove(throwable);
- }
- }
-
- if (!info.getErrors().isEmpty()) {
- showSnackBarError(new ErrorInfo(info.getErrors(),
- UserAction.REQUESTED_STREAM, info.getUrl(), info));
- }
- }
-
- binding.detailControlsDownload.setVisibility(
- StreamTypeUtil.isLiveStream(info.getStreamType()) ? View.GONE : View.VISIBLE);
- binding.detailControlsBackground.setVisibility(
- info.getAudioStreams().isEmpty() && info.getVideoStreams().isEmpty()
- ? View.GONE : View.VISIBLE);
-
- final boolean noVideoStreams =
- info.getVideoStreams().isEmpty() && info.getVideoOnlyStreams().isEmpty();
- binding.detailControlsPopup.setVisibility(noVideoStreams ? View.GONE : View.VISIBLE);
- binding.detailThumbnailPlayButton.setImageResource(
- noVideoStreams ? R.drawable.ic_headset_shadow : R.drawable.ic_play_arrow_shadow);
- }
-
- private void displayUploaderAsSubChannel(final StreamInfo info) {
- binding.detailSubChannelTextView.setText(info.getUploaderName());
- binding.detailSubChannelTextView.setVisibility(View.VISIBLE);
- binding.detailSubChannelTextView.setSelected(true);
-
- if (info.getUploaderSubscriberCount() > -1) {
- binding.detailUploaderTextView.setText(
- Localization.shortSubscriberCount(activity, info.getUploaderSubscriberCount()));
- binding.detailUploaderTextView.setVisibility(View.VISIBLE);
- } else {
- binding.detailUploaderTextView.setVisibility(View.GONE);
- }
-
- CoilHelper.INSTANCE.loadAvatar(binding.detailSubChannelThumbnailView,
- info.getUploaderAvatars());
- binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE);
- binding.detailUploaderThumbnailView.setVisibility(View.GONE);
- }
-
- private void displayBothUploaderAndSubChannel(final StreamInfo info) {
- binding.detailSubChannelTextView.setText(info.getSubChannelName());
- binding.detailSubChannelTextView.setVisibility(View.VISIBLE);
- binding.detailSubChannelTextView.setSelected(true);
-
- final StringBuilder subText = new StringBuilder();
- if (!isEmpty(info.getUploaderName())) {
- subText.append(
- String.format(getString(R.string.video_detail_by), info.getUploaderName()));
- }
- if (info.getUploaderSubscriberCount() > -1) {
- if (subText.length() > 0) {
- subText.append(Localization.DOT_SEPARATOR);
- }
- subText.append(
- Localization.shortSubscriberCount(activity, info.getUploaderSubscriberCount()));
- }
-
- if (subText.length() > 0) {
- binding.detailUploaderTextView.setText(subText);
- binding.detailUploaderTextView.setVisibility(View.VISIBLE);
- binding.detailUploaderTextView.setSelected(true);
- } else {
- binding.detailUploaderTextView.setVisibility(View.GONE);
- }
-
- CoilHelper.INSTANCE.loadAvatar(binding.detailSubChannelThumbnailView,
- info.getSubChannelAvatars());
- binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE);
- CoilHelper.INSTANCE.loadAvatar(binding.detailUploaderThumbnailView,
- info.getUploaderAvatars());
- binding.detailUploaderThumbnailView.setVisibility(View.VISIBLE);
- }
-
- public void openDownloadDialog() {
- if (currentInfo == null) {
- return;
- }
-
- try {
- final DownloadDialog downloadDialog = new DownloadDialog(activity, currentInfo);
- downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog");
- } catch (final Exception e) {
- ErrorUtil.showSnackbar(activity, new ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG,
- "Showing download dialog", currentInfo));
- }
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Stream Results
- //////////////////////////////////////////////////////////////////////////*/
-
- private void checkUpdateProgressInfo(@NonNull final StreamInfo info) {
- if (positionSubscriber != null) {
- positionSubscriber.dispose();
- }
- if (!getResumePlaybackEnabled(activity)) {
- binding.positionView.setVisibility(View.GONE);
- binding.detailPositionView.setVisibility(View.GONE);
- return;
- }
- final HistoryRecordManager recordManager = new HistoryRecordManager(requireContext());
- positionSubscriber = recordManager.loadStreamState(info)
- .subscribeOn(Schedulers.io())
- .onErrorComplete()
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(state -> {
- updatePlaybackProgress(
- state.getProgressMillis(), info.getDuration() * 1000);
- }, e -> {
- // impossible since the onErrorComplete()
- }, () -> {
- binding.positionView.setVisibility(View.GONE);
- binding.detailPositionView.setVisibility(View.GONE);
- });
- }
-
- private void updatePlaybackProgress(final long progress, final long duration) {
- if (!getResumePlaybackEnabled(activity)) {
- return;
- }
- final int progressSeconds = (int) TimeUnit.MILLISECONDS.toSeconds(progress);
- final int durationSeconds = (int) TimeUnit.MILLISECONDS.toSeconds(duration);
- // If the old and the new progress values have a big difference then use animation.
- // Otherwise don't because it affects CPU
- final int progressDifference = Math.abs(binding.positionView.getProgress()
- - progressSeconds);
- binding.positionView.setMax(durationSeconds);
- if (progressDifference > 2) {
- binding.positionView.setProgressAnimated(progressSeconds);
- } else {
- binding.positionView.setProgress(progressSeconds);
- }
- final String position = Localization.getDurationString(progressSeconds);
- if (position != binding.detailPositionView.getText()) {
- binding.detailPositionView.setText(position);
- }
- if (binding.positionView.getVisibility() != View.VISIBLE) {
- animate(binding.positionView, true, 100);
- animate(binding.detailPositionView, true, 100);
- }
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Player event listener
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override
- public void onViewCreated() {
- tryAddVideoPlayerView();
- }
-
- @Override
- public void onQueueUpdate(final PlayQueue queue) {
- playQueue = queue;
- if (DEBUG) {
- Log.d(TAG, "onQueueUpdate() called with: serviceId = ["
- + serviceId + "], url = [" + url + "], name = ["
- + title + "], playQueue = [" + playQueue + "]");
- }
-
- // Register broadcast receiver to listen to playQueue changes
- // and hide the overlayPlayQueueButton when the playQueue is empty / destroyed.
- if (playQueue != null && playQueue.getBroadcastReceiver() != null) {
- playQueue.getBroadcastReceiver().subscribe(
- event -> updateOverlayPlayQueueButtonVisibility()
- );
- }
-
- // This should be the only place where we push data to stack.
- // It will allow to have live instance of PlayQueue with actual information about
- // deleted/added items inside Channel/Playlist queue and makes possible to have
- // a history of played items
- @Nullable final StackItem stackPeek = stack.peek();
- if (stackPeek != null && !stackPeek.getPlayQueue().equals(queue)) {
- @Nullable final PlayQueueItem playQueueItem = queue.getItem();
- if (playQueueItem != null) {
- stack.push(new StackItem(playQueueItem.getServiceId(), playQueueItem.getUrl(),
- playQueueItem.getTitle(), queue));
- return;
- } // else continue below
- }
-
- @Nullable final StackItem stackWithQueue = findQueueInStack(queue);
- if (stackWithQueue != null) {
- // On every MainPlayer service's destroy() playQueue gets disposed and
- // no longer able to track progress. That's why we update our cached disposed
- // queue with the new one that is active and have the same history.
- // Without that the cached playQueue will have an old recovery position
- stackWithQueue.setPlayQueue(queue);
- }
- }
-
- @Override
- public void onPlaybackUpdate(final int state,
- final int repeatMode,
- final boolean shuffled,
- final PlaybackParameters parameters) {
- setOverlayPlayPauseImage(player != null && player.isPlaying());
-
- if (state == Player.STATE_PLAYING) {
- if (binding.positionView.getAlpha() != 1.0f
- && player.getPlayQueue() != null
- && player.getPlayQueue().getItem() != null
- && player.getPlayQueue().getItem().getUrl().equals(url)) {
- animate(binding.positionView, true, 100);
- animate(binding.detailPositionView, true, 100);
- }
- }
- }
-
- @Override
- public void onProgressUpdate(final int currentProgress,
- final int duration,
- final int bufferPercent) {
- // Progress updates every second even if media is paused. It's useless until playing
- if (!player.isPlaying() || playQueue == null) {
- return;
- }
-
- if (player.getPlayQueue().getItem().getUrl().equals(url)) {
- updatePlaybackProgress(currentProgress, duration);
- }
- }
-
- @Override
- public void onMetadataUpdate(final StreamInfo info, final PlayQueue queue) {
- final StackItem item = findQueueInStack(queue);
- if (item != null) {
- // When PlayQueue can have multiple streams (PlaylistPlayQueue or ChannelPlayQueue)
- // every new played stream gives new title and url.
- // StackItem contains information about first played stream. Let's update it here
- item.setTitle(info.getName());
- item.setUrl(info.getUrl());
- }
- // They are not equal when user watches something in popup while browsing in fragment and
- // then changes screen orientation. In that case the fragment will set itself as
- // a service listener and will receive initial call to onMetadataUpdate()
- if (!queue.equals(playQueue)) {
- return;
- }
-
- updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails());
- if (currentInfo != null && info.getUrl().equals(currentInfo.getUrl())) {
- return;
- }
-
- currentInfo = info;
- setInitialData(info.getServiceId(), info.getUrl(), info.getName(), queue);
- setAutoPlay(false);
- // Delay execution just because it freezes the main thread, and while playing
- // next/previous video you see visual glitches
- // (when non-vertical video goes after vertical video)
- prepareAndHandleInfoIfNeededAfterDelay(info, true, 200);
- }
-
- @Override
- public void onPlayerError(final PlaybackException error, final boolean isCatchableException) {
- if (!isCatchableException) {
- // Properly exit from fullscreen
- toggleFullscreenIfInFullscreenMode();
- hideMainPlayerOnLoadingNewStream();
- }
- }
-
- @Override
- public void onServiceStopped() {
- // the binding could be null at this point, if the app is finishing
- if (binding != null) {
- setOverlayPlayPauseImage(false);
- if (currentInfo != null) {
- updateOverlayData(currentInfo.getName(),
- currentInfo.getUploaderName(),
- currentInfo.getThumbnails());
- }
- updateOverlayPlayQueueButtonVisibility();
- }
- }
-
- @Override
- public void onFullscreenStateChanged(final boolean fullscreen) {
- setupBrightness();
- if (!isPlayerAndPlayerServiceAvailable()
- || player.UIs().getOpt(MainPlayerUi.class).isEmpty()
- || getRoot().map(View::getParent).isEmpty()) {
- return;
- }
-
- if (fullscreen) {
- hideSystemUiIfNeeded();
- binding.overlayPlayPauseButton.requestFocus();
- } else {
- showSystemUi();
- }
-
- if (binding.relatedItemsLayout != null) {
- binding.relatedItemsLayout.setVisibility(fullscreen ? View.GONE : View.VISIBLE);
- }
- scrollToTop();
-
- tryAddVideoPlayerView();
- }
-
- @Override
- public void onScreenRotationButtonClicked() {
- // In tablet user experience will be better if screen will not be rotated
- // from landscape to portrait every time.
- // Just turn on fullscreen mode in landscape orientation
- // or portrait & unlocked global orientation
- final boolean isLandscape = DeviceUtils.isLandscape(requireContext());
- if (DeviceUtils.isTablet(activity)
- && (!globalScreenOrientationLocked(activity) || isLandscape)) {
- player.UIs().getOpt(MainPlayerUi.class).ifPresent(MainPlayerUi::toggleFullscreen);
- return;
- }
-
- final int newOrientation = isLandscape
- ? ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
- : ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE;
-
- activity.setRequestedOrientation(newOrientation);
- }
-
- /*
- * Will scroll down to description view after long click on moreOptionsButton
- * */
- @Override
- public void onMoreOptionsLongClicked() {
- final CoordinatorLayout.LayoutParams params =
- (CoordinatorLayout.LayoutParams) binding.appBarLayout.getLayoutParams();
- final AppBarLayout.Behavior behavior = (AppBarLayout.Behavior) params.getBehavior();
- final ValueAnimator valueAnimator = ValueAnimator
- .ofInt(0, -binding.playerPlaceholder.getHeight());
- valueAnimator.setInterpolator(new DecelerateInterpolator());
- valueAnimator.addUpdateListener(animation -> {
- behavior.setTopAndBottomOffset((int) animation.getAnimatedValue());
- binding.appBarLayout.requestLayout();
- });
- valueAnimator.setInterpolator(new DecelerateInterpolator());
- valueAnimator.setDuration(500);
- valueAnimator.start();
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Player related utils
- //////////////////////////////////////////////////////////////////////////*/
-
- private void showSystemUi() {
- if (DEBUG) {
- Log.d(TAG, "showSystemUi() called");
- }
-
- if (activity == null) {
- return;
- }
-
- // Prevent jumping of the player on devices with cutout
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- activity.getWindow().getAttributes().layoutInDisplayCutoutMode =
- WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT;
- }
- activity.getWindow().getDecorView().setSystemUiVisibility(0);
- activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
- activity.getWindow().setStatusBarColor(ThemeHelper.resolveColorFromAttr(
- requireContext(), android.R.attr.colorPrimary));
- }
-
- private void hideSystemUi() {
- if (DEBUG) {
- Log.d(TAG, "hideSystemUi() called");
- }
-
- if (activity == null) {
- return;
- }
-
- // Prevent jumping of the player on devices with cutout
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- activity.getWindow().getAttributes().layoutInDisplayCutoutMode =
- WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
- }
- int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
- | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
- | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
- | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
- | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
-
- // In multiWindow mode status bar is not transparent for devices with cutout
- // if I include this flag. So without it is better in this case
- final boolean isInMultiWindow = DeviceUtils.isInMultiWindow(activity);
- if (!isInMultiWindow) {
- visibility |= View.SYSTEM_UI_FLAG_FULLSCREEN;
- }
- activity.getWindow().getDecorView().setSystemUiVisibility(visibility);
-
- if (isInMultiWindow || isFullscreen()) {
- activity.getWindow().setStatusBarColor(Color.TRANSPARENT);
- activity.getWindow().setNavigationBarColor(Color.TRANSPARENT);
- }
- activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
- }
-
- // Listener implementation
- @Override
- public void hideSystemUiIfNeeded() {
- if (isFullscreen()
- && bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) {
- hideSystemUi();
- }
- }
-
- private boolean isFullscreen() {
- return isPlayerAvailable() && player.UIs().getOpt(VideoPlayerUi.class)
- .map(VideoPlayerUi::isFullscreen).orElse(false);
- }
-
- private boolean playerIsNotStopped() {
- return isPlayerAvailable() && !player.isStopped();
- }
-
- private void restoreDefaultBrightness() {
- final WindowManager.LayoutParams lp = activity.getWindow().getAttributes();
- if (lp.screenBrightness == -1) {
- return;
- }
-
- // Restore the old brightness when fragment.onPause() called or
- // when a player is in portrait
- lp.screenBrightness = -1;
- activity.getWindow().setAttributes(lp);
- }
-
- private void setupBrightness() {
- if (activity == null) {
- return;
- }
-
- final WindowManager.LayoutParams lp = activity.getWindow().getAttributes();
- if (!isFullscreen() || bottomSheetState != BottomSheetBehavior.STATE_EXPANDED) {
- // Apply system brightness when the player is not in fullscreen
- restoreDefaultBrightness();
- } else {
- // Do not restore if user has disabled brightness gesture
- if (!PlayerHelper.getActionForRightGestureSide(activity)
- .equals(getString(R.string.brightness_control_key))
- && !PlayerHelper.getActionForLeftGestureSide(activity)
- .equals(getString(R.string.brightness_control_key))) {
- return;
- }
- // Restore already saved brightness level
- final float brightnessLevel = PlayerHelper.getScreenBrightness(activity);
- if (brightnessLevel == lp.screenBrightness) {
- return;
- }
- lp.screenBrightness = brightnessLevel;
- activity.getWindow().setAttributes(lp);
- }
- }
-
- /**
- * Make changes to the UI to accommodate for better usability on bigger screens such as TVs
- * or in Android's desktop mode (DeX etc).
- */
- private void accommodateForTvAndDesktopMode() {
- if (DeviceUtils.isTv(getContext())) {
- // remove ripple effects from detail controls
- final int transparent = ContextCompat.getColor(requireContext(),
- R.color.transparent_background_color);
- binding.detailControlsPlaylistAppend.setBackgroundColor(transparent);
- binding.detailControlsBackground.setBackgroundColor(transparent);
- binding.detailControlsPopup.setBackgroundColor(transparent);
- binding.detailControlsDownload.setBackgroundColor(transparent);
- binding.detailControlsShare.setBackgroundColor(transparent);
- binding.detailControlsOpenInBrowser.setBackgroundColor(transparent);
- binding.detailControlsPlayWithKodi.setBackgroundColor(transparent);
- }
- if (DeviceUtils.isDesktopMode(getContext())) {
- // Remove the "hover" overlay (since it is visible on all mouse events and interferes
- // with the video content being played)
- binding.detailThumbnailRootLayout.setForeground(null);
- }
- }
-
- private void checkLandscape() {
- if ((!player.isPlaying() && player.getPlayQueue() != playQueue)
- || player.getPlayQueue() == null) {
- setAutoPlay(true);
- }
-
- player.UIs().getOpt(MainPlayerUi.class).ifPresent(MainPlayerUi::checkLandscape);
- // Let's give a user time to look at video information page if video is not playing
- if (globalScreenOrientationLocked(activity) && !player.isPlaying()) {
- player.play();
- }
- }
-
- /*
- * Means that the player fragment was swiped away via BottomSheetLayout
- * and is empty but ready for any new actions. See cleanUp()
- * */
- private boolean wasCleared() {
- return url == null;
- }
-
- @Nullable
- private StackItem findQueueInStack(final PlayQueue queue) {
- StackItem item = null;
- final Iterator iterator = stack.descendingIterator();
- while (iterator.hasNext()) {
- final StackItem next = iterator.next();
- if (next.getPlayQueue().equals(queue)) {
- item = next;
- break;
- }
- }
- return item;
- }
-
- private void replaceQueueIfUserConfirms(final Runnable onAllow) {
- @Nullable final PlayQueue activeQueue = isPlayerAvailable() ? player.getPlayQueue() : null;
-
- // Player will have STATE_IDLE when a user pressed back button
- if (isClearingQueueConfirmationRequired(activity)
- && playerIsNotStopped()
- && !Objects.equals(activeQueue, playQueue)) {
- showClearingQueueConfirmation(onAllow);
- } else {
- onAllow.run();
- }
- }
-
- private void showClearingQueueConfirmation(final Runnable onAllow) {
- new AlertDialog.Builder(activity)
- .setTitle(R.string.clear_queue_confirmation_description)
- .setNegativeButton(R.string.cancel, null)
- .setPositiveButton(R.string.ok, (dialog, which) -> {
- onAllow.run();
- dialog.dismiss();
- })
- .show();
- }
-
- private void showExternalVideoPlaybackDialog() {
- if (currentInfo == null) {
- return;
- }
-
- final AlertDialog.Builder builder = new AlertDialog.Builder(activity);
- builder.setTitle(R.string.select_quality_external_players);
- builder.setNeutralButton(R.string.open_in_browser, (dialog, i) ->
- ShareUtils.openUrlInBrowser(requireActivity(), url));
-
- final List videoStreamsForExternalPlayers =
- ListHelper.getSortedStreamVideosList(
- activity,
- getUrlAndNonTorrentStreams(currentInfo.getVideoStreams()),
- getUrlAndNonTorrentStreams(currentInfo.getVideoOnlyStreams()),
- false,
- false
- );
-
- if (videoStreamsForExternalPlayers.isEmpty()) {
- builder.setMessage(R.string.no_video_streams_available_for_external_players);
- builder.setPositiveButton(R.string.ok, null);
-
- } else {
- final int selectedVideoStreamIndexForExternalPlayers =
- ListHelper.getDefaultResolutionIndex(activity, videoStreamsForExternalPlayers);
- final CharSequence[] resolutions = videoStreamsForExternalPlayers.stream()
- .map(VideoStream::getResolution).toArray(CharSequence[]::new);
-
- builder.setSingleChoiceItems(resolutions, selectedVideoStreamIndexForExternalPlayers,
- null);
- builder.setNegativeButton(R.string.cancel, null);
- builder.setPositiveButton(R.string.ok, (dialog, i) -> {
- final int index = ((AlertDialog) dialog).getListView().getCheckedItemPosition();
- // We don't have to manage the index validity because if there is no stream
- // available for external players, this code will be not executed and if there is
- // no stream which matches the default resolution, 0 is returned by
- // ListHelper.getDefaultResolutionIndex.
- // The index cannot be outside the bounds of the list as its always between 0 and
- // the list size - 1, .
- startOnExternalPlayer(activity, currentInfo,
- videoStreamsForExternalPlayers.get(index));
- });
- }
- builder.show();
- }
-
- private void showExternalAudioPlaybackDialog() {
- if (currentInfo == null) {
- return;
- }
-
- final List audioStreams = getUrlAndNonTorrentStreams(
- currentInfo.getAudioStreams());
- final List audioTracks =
- ListHelper.getFilteredAudioStreams(activity, audioStreams);
-
- if (audioTracks.isEmpty()) {
- Toast.makeText(activity, R.string.no_audio_streams_available_for_external_players,
- Toast.LENGTH_SHORT).show();
- } else if (audioTracks.size() == 1) {
- startOnExternalPlayer(activity, currentInfo, audioTracks.get(0));
- } else {
- final int selectedAudioStream =
- ListHelper.getDefaultAudioFormat(activity, audioTracks);
- final CharSequence[] trackNames = audioTracks.stream()
- .map(audioStream -> Localization.audioTrackName(activity, audioStream))
- .toArray(CharSequence[]::new);
-
- new AlertDialog.Builder(activity)
- .setTitle(R.string.select_audio_track_external_players)
- .setNeutralButton(R.string.open_in_browser, (dialog, i) ->
- ShareUtils.openUrlInBrowser(requireActivity(), url))
- .setSingleChoiceItems(trackNames, selectedAudioStream, null)
- .setNegativeButton(R.string.cancel, null)
- .setPositiveButton(R.string.ok, (dialog, i) -> {
- final int index = ((AlertDialog) dialog).getListView()
- .getCheckedItemPosition();
- startOnExternalPlayer(activity, currentInfo, audioTracks.get(index));
- })
- .show();
- }
- }
-
- /*
- * Remove unneeded information while waiting for a next task
- * */
- private void cleanUp() {
- // New beginning
- stack.clear();
- if (currentWorker != null) {
- currentWorker.dispose();
- }
- playerHolder.stopService();
- setInitialData(0, null, "", null);
- currentInfo = null;
- updateOverlayData(null, null, List.of());
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Bottom mini player
- //////////////////////////////////////////////////////////////////////////*/
-
- /**
- * That's for Android TV support. Move focus from main fragment to the player or back
- * based on what is currently selected
- *
- * @param toMain if true than the main fragment will be focused or the player otherwise
- */
- private void moveFocusToMainFragment(final boolean toMain) {
- setupBrightness();
- final ViewGroup mainFragment = requireActivity().findViewById(R.id.fragment_holder);
- // Hamburger button steels a focus even under bottomSheet
- final Toolbar toolbar = requireActivity().findViewById(R.id.toolbar);
- final int afterDescendants = ViewGroup.FOCUS_AFTER_DESCENDANTS;
- final int blockDescendants = ViewGroup.FOCUS_BLOCK_DESCENDANTS;
- if (toMain) {
- mainFragment.setDescendantFocusability(afterDescendants);
- toolbar.setDescendantFocusability(afterDescendants);
- ((ViewGroup) requireView()).setDescendantFocusability(blockDescendants);
- // Only focus the mainFragment if the mainFragment (e.g. search-results)
- // or the toolbar (e.g. Textfield for search) don't have focus.
- // This was done to fix problems with the keyboard input, see also #7490
- if (!mainFragment.hasFocus() && !toolbar.hasFocus()) {
- mainFragment.requestFocus();
- }
- } else {
- mainFragment.setDescendantFocusability(blockDescendants);
- toolbar.setDescendantFocusability(blockDescendants);
- ((ViewGroup) requireView()).setDescendantFocusability(afterDescendants);
- // Only focus the player if it not already has focus
- if (!binding.getRoot().hasFocus()) {
- binding.detailThumbnailRootLayout.requestFocus();
- }
- }
- }
-
- /**
- * When the mini player exists the view underneath it is not touchable.
- * Bottom padding should be equal to the mini player's height in this case
- *
- * @param showMore whether main fragment should be expanded or not
- */
- private void manageSpaceAtTheBottom(final boolean showMore) {
- final int peekHeight = getResources().getDimensionPixelSize(R.dimen.mini_player_height);
- final ViewGroup holder = requireActivity().findViewById(R.id.fragment_holder);
- final int newBottomPadding;
- if (showMore) {
- newBottomPadding = 0;
- } else {
- newBottomPadding = peekHeight;
- }
- if (holder.getPaddingBottom() == newBottomPadding) {
- return;
- }
- holder.setPadding(holder.getPaddingLeft(),
- holder.getPaddingTop(),
- holder.getPaddingRight(),
- newBottomPadding);
- }
-
- private void setupBottomPlayer() {
- final CoordinatorLayout.LayoutParams params =
- (CoordinatorLayout.LayoutParams) binding.appBarLayout.getLayoutParams();
- final AppBarLayout.Behavior behavior = (AppBarLayout.Behavior) params.getBehavior();
-
- final FrameLayout bottomSheetLayout = activity.findViewById(R.id.fragment_player_holder);
- bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout);
- bottomSheetBehavior.setState(lastStableBottomSheetState);
- updateBottomSheetState(lastStableBottomSheetState);
-
- final int peekHeight = getResources().getDimensionPixelSize(R.dimen.mini_player_height);
- if (bottomSheetState != BottomSheetBehavior.STATE_HIDDEN) {
- manageSpaceAtTheBottom(false);
- bottomSheetBehavior.setPeekHeight(peekHeight);
- if (bottomSheetState == BottomSheetBehavior.STATE_COLLAPSED) {
- binding.overlayLayout.setAlpha(MAX_OVERLAY_ALPHA);
- } else if (bottomSheetState == BottomSheetBehavior.STATE_EXPANDED) {
- binding.overlayLayout.setAlpha(0);
- setOverlayElementsClickable(false);
- }
- }
-
- bottomSheetCallback = new BottomSheetBehavior.BottomSheetCallback() {
- @Override
- public void onStateChanged(@NonNull final View bottomSheet, final int newState) {
- updateBottomSheetState(newState);
-
- switch (newState) {
- case BottomSheetBehavior.STATE_HIDDEN:
- moveFocusToMainFragment(true);
- manageSpaceAtTheBottom(true);
-
- bottomSheetBehavior.setPeekHeight(0);
- cleanUp();
- break;
- case BottomSheetBehavior.STATE_EXPANDED:
- moveFocusToMainFragment(false);
- manageSpaceAtTheBottom(false);
-
- bottomSheetBehavior.setPeekHeight(peekHeight);
- // Disable click because overlay buttons located on top of buttons
- // from the player
- setOverlayElementsClickable(false);
- hideSystemUiIfNeeded();
- // Conditions when the player should be expanded to fullscreen
- if (DeviceUtils.isLandscape(requireContext())
- && isPlayerAvailable()
- && player.isPlaying()
- && !isFullscreen()
- && !DeviceUtils.isTablet(activity)) {
- player.UIs().getOpt(MainPlayerUi.class)
- .ifPresent(MainPlayerUi::toggleFullscreen);
- }
- setOverlayLook(binding.appBarLayout, behavior, 1);
- break;
- case BottomSheetBehavior.STATE_COLLAPSED:
- moveFocusToMainFragment(true);
- manageSpaceAtTheBottom(false);
-
- bottomSheetBehavior.setPeekHeight(peekHeight);
-
- // Re-enable clicks
- setOverlayElementsClickable(true);
- if (isPlayerAvailable()) {
- player.UIs().getOpt(MainPlayerUi.class)
- .ifPresent(MainPlayerUi::closeItemsList);
- }
- setOverlayLook(binding.appBarLayout, behavior, 0);
- break;
- case BottomSheetBehavior.STATE_DRAGGING:
- case BottomSheetBehavior.STATE_SETTLING:
- if (isFullscreen()) {
- showSystemUi();
- }
- if (isPlayerAvailable()) {
- player.UIs().getOpt(MainPlayerUi.class).ifPresent(ui -> {
- if (ui.isControlsVisible()) {
- ui.hideControls(0, 0);
- }
- });
- }
- break;
- case BottomSheetBehavior.STATE_HALF_EXPANDED:
- break;
- }
- }
-
- @Override
- public void onSlide(@NonNull final View bottomSheet, final float slideOffset) {
- setOverlayLook(binding.appBarLayout, behavior, slideOffset);
- }
- };
-
- bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback);
-
- // User opened a new page and the player will hide itself
- activity.getSupportFragmentManager().addOnBackStackChangedListener(() -> {
- if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) {
- bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
- }
- });
- }
-
- private void updateOverlayPlayQueueButtonVisibility() {
- final boolean isPlayQueueEmpty =
- player == null // no player => no play queue :)
- || player.getPlayQueue() == null
- || player.getPlayQueue().isEmpty();
- if (binding != null) {
- // binding is null when rotating the device...
- binding.overlayPlayQueueButton.setVisibility(
- isPlayQueueEmpty ? View.GONE : View.VISIBLE);
- }
- }
-
- private void updateOverlayData(@Nullable final String overlayTitle,
- @Nullable final String uploader,
- @NonNull final List thumbnails) {
- binding.overlayTitleTextView.setText(isEmpty(overlayTitle) ? "" : overlayTitle);
- binding.overlayChannelTextView.setText(isEmpty(uploader) ? "" : uploader);
- binding.overlayThumbnail.setImageDrawable(null);
- CoilHelper.INSTANCE.loadDetailsThumbnail(binding.overlayThumbnail, thumbnails);
- }
-
- private void setOverlayPlayPauseImage(final boolean playerIsPlaying) {
- final int drawable = playerIsPlaying
- ? R.drawable.ic_pause
- : R.drawable.ic_play_arrow;
- binding.overlayPlayPauseButton.setImageResource(drawable);
- }
-
- private void setOverlayLook(final AppBarLayout appBar,
- final AppBarLayout.Behavior behavior,
- final float slideOffset) {
- // SlideOffset < 0 when mini player is about to close via swipe.
- // Stop animation in this case
- if (behavior == null || slideOffset < 0) {
- return;
- }
- binding.overlayLayout.setAlpha(Math.min(MAX_OVERLAY_ALPHA, 1 - slideOffset));
- // These numbers are not special. They just do a cool transition
- behavior.setTopAndBottomOffset(
- (int) (-binding.detailThumbnailImageView.getHeight() * 2 * (1 - slideOffset) / 3));
- appBar.requestLayout();
- }
-
- private void setOverlayElementsClickable(final boolean enable) {
- binding.overlayThumbnail.setClickable(enable);
- binding.overlayThumbnail.setLongClickable(enable);
- binding.overlayMetadataLayout.setClickable(enable);
- binding.overlayMetadataLayout.setLongClickable(enable);
- binding.overlayButtonsLayout.setClickable(enable);
- binding.overlayPlayQueueButton.setClickable(enable);
- binding.overlayPlayPauseButton.setClickable(enable);
- binding.overlayCloseButton.setClickable(enable);
- }
-
- // helpers to check the state of player and playerService
- boolean isPlayerAvailable() {
- return player != null;
- }
-
- boolean noPlayerServiceAvailable() {
- return playerService == null;
- }
-
- boolean isPlayerAndPlayerServiceAvailable() {
- return player != null && playerService != null;
- }
-
- public Optional getRoot() {
- return Optional.ofNullable(player)
- .flatMap(player1 -> player1.UIs().getOpt(VideoPlayerUi.class))
- .map(playerUi -> playerUi.getBinding().getRoot());
- }
-
- private void updateBottomSheetState(final int newState) {
- bottomSheetState = newState;
- if (newState != BottomSheetBehavior.STATE_DRAGGING
- && newState != BottomSheetBehavior.STATE_SETTLING) {
- lastStableBottomSheetState = newState;
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt
new file mode 100644
index 000000000..ad9d21481
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt
@@ -0,0 +1,2808 @@
+package org.schabi.newpipe.fragments.detail
+
+import android.animation.ValueAnimator
+import android.animation.ValueAnimator.AnimatorUpdateListener
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.DialogInterface
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.SharedPreferences
+import android.content.SharedPreferences.OnSharedPreferenceChangeListener
+import android.content.pm.ActivityInfo
+import android.database.ContentObserver
+import android.graphics.Color
+import android.graphics.Rect
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.provider.Settings
+import android.text.TextUtils
+import android.util.DisplayMetrics
+import android.util.Log
+import android.util.TypedValue
+import android.view.LayoutInflater
+import android.view.MotionEvent
+import android.view.View
+import android.view.View.OnLongClickListener
+import android.view.View.OnTouchListener
+import android.view.ViewGroup
+import android.view.ViewParent
+import android.view.ViewTreeObserver
+import android.view.WindowManager
+import android.view.animation.DecelerateInterpolator
+import android.widget.FrameLayout
+import android.widget.RelativeLayout
+import android.widget.Toast
+import androidx.annotation.AttrRes
+import androidx.annotation.StringRes
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.content.res.AppCompatResources
+import androidx.appcompat.widget.Toolbar
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.FragmentManager
+import androidx.preference.PreferenceManager
+import coil3.util.CoilUtils.dispose
+import com.evernote.android.state.State
+import com.google.android.exoplayer2.PlaybackException
+import com.google.android.exoplayer2.PlaybackParameters
+import com.google.android.material.appbar.AppBarLayout
+import com.google.android.material.appbar.AppBarLayout.OnOffsetChangedListener
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
+import io.reactivex.rxjava3.disposables.CompositeDisposable
+import io.reactivex.rxjava3.disposables.Disposable
+import io.reactivex.rxjava3.functions.Action
+import io.reactivex.rxjava3.schedulers.Schedulers
+import org.schabi.newpipe.App
+import org.schabi.newpipe.R
+import org.schabi.newpipe.database.stream.model.StreamEntity
+import org.schabi.newpipe.database.stream.model.StreamStateEntity
+import org.schabi.newpipe.databinding.FragmentVideoDetailBinding
+import org.schabi.newpipe.download.DownloadDialog
+import org.schabi.newpipe.error.ErrorInfo
+import org.schabi.newpipe.error.ErrorUtil.Companion.showSnackbar
+import org.schabi.newpipe.error.ErrorUtil.Companion.showUiErrorSnackbar
+import org.schabi.newpipe.error.ReCaptchaActivity
+import org.schabi.newpipe.error.UserAction
+import org.schabi.newpipe.extractor.Image
+import org.schabi.newpipe.extractor.NewPipe
+import org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability
+import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
+import org.schabi.newpipe.extractor.exceptions.ExtractionException
+import org.schabi.newpipe.extractor.stream.AudioStream
+import org.schabi.newpipe.extractor.stream.Stream
+import org.schabi.newpipe.extractor.stream.StreamExtractor
+import org.schabi.newpipe.extractor.stream.StreamInfo
+import org.schabi.newpipe.extractor.stream.StreamType
+import org.schabi.newpipe.extractor.stream.VideoStream
+import org.schabi.newpipe.fragments.BackPressable
+import org.schabi.newpipe.fragments.BaseStateFragment
+import org.schabi.newpipe.fragments.EmptyFragment
+import org.schabi.newpipe.fragments.MainFragment
+import org.schabi.newpipe.fragments.list.comments.CommentsFragment.Companion.getInstance
+import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment.Companion.getInstance
+import org.schabi.newpipe.ktx.AnimationType
+import org.schabi.newpipe.ktx.animate
+import org.schabi.newpipe.ktx.animateRotation
+import org.schabi.newpipe.local.dialog.PlaylistDialog
+import org.schabi.newpipe.local.history.HistoryRecordManager
+import org.schabi.newpipe.local.playlist.LocalPlaylistFragment
+import org.schabi.newpipe.player.Player
+import org.schabi.newpipe.player.PlayerService
+import org.schabi.newpipe.player.PlayerType
+import org.schabi.newpipe.player.event.OnKeyDownListener
+import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener
+import org.schabi.newpipe.player.helper.PlayerHelper
+import org.schabi.newpipe.player.helper.PlayerHolder.Companion.getInstance
+import org.schabi.newpipe.player.playqueue.PlayQueue
+import org.schabi.newpipe.player.playqueue.SinglePlayQueue
+import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent
+import org.schabi.newpipe.player.ui.MainPlayerUi
+import org.schabi.newpipe.player.ui.VideoPlayerUi
+import org.schabi.newpipe.util.DependentPreferenceHelper
+import org.schabi.newpipe.util.DeviceUtils
+import org.schabi.newpipe.util.ExtractorHelper
+import org.schabi.newpipe.util.InfoCache
+import org.schabi.newpipe.util.ListHelper
+import org.schabi.newpipe.util.Localization
+import org.schabi.newpipe.util.NavigationHelper
+import org.schabi.newpipe.util.PermissionHelper
+import org.schabi.newpipe.util.PlayButtonHelper
+import org.schabi.newpipe.util.StreamTypeUtil
+import org.schabi.newpipe.util.ThemeHelper
+import org.schabi.newpipe.util.external_communication.KoreUtils
+import org.schabi.newpipe.util.external_communication.ShareUtils
+import org.schabi.newpipe.util.image.CoilHelper
+import org.schabi.newpipe.util.image.CoilHelper.loadAvatar
+import org.schabi.newpipe.util.image.CoilHelper.loadDetailsThumbnail
+import java.util.LinkedList
+import java.util.List
+import java.util.Objects
+import java.util.Optional
+import java.util.concurrent.TimeUnit
+import java.util.function.Consumer
+import java.util.function.Function
+import kotlin.math.abs
+import kotlin.math.max
+import kotlin.math.min
+
+class VideoDetailFragment :
+
+ BaseStateFragment(),
+ BackPressable,
+ PlayerServiceExtendedEventListener,
+ OnKeyDownListener {
+ // tabs
+ private var showComments = false
+ private var showRelatedItems = false
+ private var showDescription = false
+ private var selectedTabTag: String? = null
+
+ @AttrRes
+ val tabIcons: MutableList = ArrayList()
+
+ @StringRes
+ val tabContentDescriptions: MutableList = ArrayList()
+ private var tabSettingsChanged = false
+ private var lastAppBarVerticalOffset = Int.Companion.MAX_VALUE // prevents useless updates
+
+ private val preferenceChangeListener =
+ OnSharedPreferenceChangeListener { sharedPreferences: SharedPreferences?, key: String? ->
+ if (getString(R.string.show_comments_key) == key) {
+ showComments = sharedPreferences!!.getBoolean(key, true)
+ tabSettingsChanged = true
+ } else if (getString(R.string.show_next_video_key) == key) {
+ showRelatedItems = sharedPreferences!!.getBoolean(key, true)
+ tabSettingsChanged = true
+ } else if (getString(R.string.show_description_key) == key) {
+ showDescription = sharedPreferences!!.getBoolean(key, true)
+ tabSettingsChanged = true
+ }
+ }
+
+ @JvmField
+ @State
+ var serviceId: Int = NO_SERVICE_ID
+
+ @JvmField
+ @State
+ var title: String = ""
+
+ @JvmField
+ @State
+ var url: String? = null
+ private var playQueue: PlayQueue? = null
+
+ @JvmField
+ @State
+ var bottomSheetState: Int = BottomSheetBehavior.STATE_EXPANDED
+
+ @JvmField
+ @State
+ var lastStableBottomSheetState: Int = BottomSheetBehavior.STATE_EXPANDED
+
+ @JvmField
+ @State
+ var autoPlayEnabled: Boolean = true
+
+ private var currentInfo: StreamInfo? = null
+ private var currentWorker: Disposable? = null
+ private val disposables = CompositeDisposable()
+ private var positionSubscriber: Disposable? = null
+
+ private var bottomSheetBehavior: BottomSheetBehavior? = null
+ private var bottomSheetCallback: BottomSheetCallback? = null
+ private var broadcastReceiver: BroadcastReceiver? = null
+
+ /*//////////////////////////////////////////////////////////////////////////
+ // Views
+ ////////////////////////////////////////////////////////////////////////// */
+ private var binding: FragmentVideoDetailBinding? = null
+
+ private var pageAdapter: TabAdapter? = null
+
+ private var settingsContentObserver: ContentObserver? = null
+ private var playerService: PlayerService? = null
+ private var player: Player? = null
+ private val playerHolder = getInstance()
+
+ /*//////////////////////////////////////////////////////////////////////////
+ // Service management
+ ////////////////////////////////////////////////////////////////////////// */
+ override fun onServiceConnected(connectedPlayerService: PlayerService) {
+ playerService = connectedPlayerService
+ }
+
+ override fun onPlayerConnected(
+ connectedPlayer: Player,
+ playAfterConnect: Boolean
+ ) {
+ player = connectedPlayer
+
+ // It will do nothing if the player is not in fullscreen mode
+ hideSystemUiIfNeeded()
+
+ val playerUi: Optional =
+ player!!.UIs().getOpt(MainPlayerUi::class.java)
+ if (!player!!.videoPlayerSelected() && !playAfterConnect) {
+ return
+ }
+
+ if (DeviceUtils.isLandscape(requireContext())) {
+ // If the video is playing but orientation changed
+ // let's make the video in fullscreen again
+ checkLandscape()
+ } else if (playerUi.map(Function { ui: MainPlayerUi? -> ui!!.isFullscreen() && !ui.isVerticalVideo() })
+ .orElse(false) && // Tablet UI has orientation-independent fullscreen
+ !DeviceUtils.isTablet(activity)
+ ) {
+ // Device is in portrait orientation after rotation but UI is in fullscreen.
+ // Return back to non-fullscreen state
+ playerUi.ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.toggleFullscreen() })
+ }
+
+ if (playAfterConnect ||
+ (
+ currentInfo != null && this.isAutoplayEnabled &&
+ playerUi.isEmpty()
+ )
+ ) {
+ autoPlayEnabled = true // forcefully start playing
+ openVideoPlayerAutoFullscreen()
+ }
+ updateOverlayPlayQueueButtonVisibility()
+ }
+
+ override fun onPlayerDisconnected() {
+ player = null
+ // the binding could be null at this point, if the app is finishing
+ if (binding != null) {
+ restoreDefaultBrightness()
+ }
+ }
+
+ override fun onServiceDisconnected() {
+ playerService = null
+ }
+
+ /*//////////////////////////////////////////////////////////////////////////
+ // Fragment's Lifecycle
+ ////////////////////////////////////////////////////////////////////////// */
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val prefs = PreferenceManager.getDefaultSharedPreferences(activity)
+ showComments = prefs.getBoolean(getString(R.string.show_comments_key), true)
+ showRelatedItems = prefs.getBoolean(getString(R.string.show_next_video_key), true)
+ showDescription = prefs.getBoolean(getString(R.string.show_description_key), true)
+ selectedTabTag = prefs.getString(
+ getString(R.string.stream_info_selected_tab_key), COMMENTS_TAB_TAG
+ )
+ prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
+
+ setupBroadcastReceiver()
+
+ settingsContentObserver = object : ContentObserver(Handler()) {
+ override fun onChange(selfChange: Boolean) {
+ if (activity != null && !PlayerHelper.globalScreenOrientationLocked(activity)) {
+ activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED)
+ }
+ }
+ }
+ activity.getContentResolver().registerContentObserver(
+ Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false,
+ settingsContentObserver!!
+ )
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ binding = FragmentVideoDetailBinding.inflate(inflater, container, false)
+ return binding!!.getRoot()
+ }
+
+ override fun onPause() {
+ super.onPause()
+ if (currentWorker != null) {
+ currentWorker!!.dispose()
+ }
+ restoreDefaultBrightness()
+ PreferenceManager.getDefaultSharedPreferences(requireContext())
+ .edit()
+ .putString(
+ getString(R.string.stream_info_selected_tab_key),
+ pageAdapter!!.getItemTitle(binding!!.viewPager.getCurrentItem())
+ )
+ .apply()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ if (DEBUG) {
+ Log.d(TAG, "onResume() called")
+ }
+
+ activity.sendBroadcast(Intent(ACTION_VIDEO_FRAGMENT_RESUMED))
+
+ updateOverlayPlayQueueButtonVisibility()
+
+ setupBrightness()
+
+ if (tabSettingsChanged) {
+ tabSettingsChanged = false
+ initTabs()
+ if (currentInfo != null) {
+ updateTabs(currentInfo!!)
+ }
+ }
+
+ // Check if it was loading when the fragment was stopped/paused
+ if (wasLoading.getAndSet(false) && !wasCleared()) {
+ startLoading(false)
+ }
+ }
+
+ override fun onStop() {
+ super.onStop()
+
+ if (!activity.isChangingConfigurations()) {
+ activity.sendBroadcast(Intent(ACTION_VIDEO_FRAGMENT_STOPPED))
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+
+ // Stop the service when user leaves the app with double back press
+ // if video player is selected. Otherwise unbind
+ if (activity.isFinishing() && this.isPlayerAvailable && player!!.videoPlayerSelected()) {
+ playerHolder.stopService()
+ } else {
+ playerHolder.setListener(null)
+ }
+
+ PreferenceManager.getDefaultSharedPreferences(activity)
+ .unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
+ activity.unregisterReceiver(broadcastReceiver)
+ activity.getContentResolver().unregisterContentObserver(settingsContentObserver!!)
+
+ if (positionSubscriber != null) {
+ positionSubscriber!!.dispose()
+ }
+ if (currentWorker != null) {
+ currentWorker!!.dispose()
+ }
+ disposables.clear()
+ positionSubscriber = null
+ currentWorker = null
+ bottomSheetBehavior!!.removeBottomSheetCallback(bottomSheetCallback!!)
+
+ if (activity.isFinishing()) {
+ playQueue = null
+ currentInfo = null
+ stack = LinkedList()
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ binding = null
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+ if (requestCode == ReCaptchaActivity.RECAPTCHA_REQUEST) {
+ if (resultCode == Activity.RESULT_OK) {
+ NavigationHelper.openVideoDetailFragment(
+ requireContext(), getFM(),
+ serviceId, url, title, null, false
+ )
+ } else {
+ Log.e(TAG, "ReCaptcha failed")
+ }
+ } else {
+ Log.e(TAG, "Request code from activity not supported [" + requestCode + "]")
+ }
+ }
+
+ /*//////////////////////////////////////////////////////////////////////////
+ // OnClick
+ ////////////////////////////////////////////////////////////////////////// */
+ private fun setOnClickListeners() {
+ binding!!.detailTitleRootLayout.setOnClickListener(View.OnClickListener { v: View? -> toggleTitleAndSecondaryControls() })
+ binding!!.detailUploaderRootLayout.setOnClickListener(
+ makeOnClickListener(
+ Consumer { info: StreamInfo? ->
+ if (TextUtils.isEmpty(
+ info!!.getSubChannelUrl()
+ )
+ ) {
+ if (!TextUtils.isEmpty(info.getUploaderUrl())) {
+ openChannel(info.getUploaderUrl(), info.getUploaderName())
+ }
+
+ if (DEBUG) {
+ Log.i(TAG, "Can't open sub-channel because we got no channel URL")
+ }
+ } else {
+ openChannel(info.getSubChannelUrl(), info.getSubChannelName())
+ }
+ }
+ )
+ )
+ binding!!.detailThumbnailRootLayout.setOnClickListener(
+ View.OnClickListener { v: View? ->
+ autoPlayEnabled = true // forcefully start playing
+ // FIXME Workaround #7427
+ if (this.isPlayerAvailable) {
+ player!!.setRecovery()
+ }
+ openVideoPlayerAutoFullscreen()
+ }
+ )
+
+ binding!!.detailControlsBackground.setOnClickListener(
+ View.OnClickListener { v: View? ->
+ openBackgroundPlayer(
+ false
+ )
+ }
+ )
+ binding!!.detailControlsPopup.setOnClickListener(
+ View.OnClickListener { v: View? ->
+ openPopupPlayer(
+ false
+ )
+ }
+ )
+ binding!!.detailControlsPlaylistAppend.setOnClickListener(
+ makeOnClickListener(
+ Consumer { info: StreamInfo? ->
+ if (getFM() != null && currentInfo != null) {
+ val fragment = getParentFragmentManager().findFragmentById
+ (R.id.fragment_holder)
+
+ // commit previous pending changes to database
+ if (fragment is LocalPlaylistFragment) {
+ fragment.saveImmediate()
+ } else if (fragment is MainFragment) {
+ fragment.commitPlaylistTabs()
+ }
+
+ disposables.add(
+ PlaylistDialog.createCorrespondingDialog(
+ requireContext(),
+ List.of(StreamEntity(info!!)),
+ Consumer { dialog: PlaylistDialog? ->
+ dialog!!.show(
+ getParentFragmentManager(),
+ TAG
+ )
+ }
+ )
+ )
+ }
+ }
+ )
+ )
+ binding!!.detailControlsDownload.setOnClickListener(
+ View.OnClickListener { v: View? ->
+ if (PermissionHelper.checkStoragePermissions(
+ activity,
+ PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE
+ )
+ ) {
+ openDownloadDialog()
+ }
+ }
+ )
+ binding!!.detailControlsShare.setOnClickListener(
+ makeOnClickListener(
+ Consumer { info: StreamInfo? ->
+ ShareUtils.shareText(
+ requireContext(), info!!.getName(), info.getUrl(),
+ info.getThumbnails()
+ )
+ }
+ )
+ )
+ binding!!.detailControlsOpenInBrowser.setOnClickListener(
+ makeOnClickListener(
+ Consumer { info: StreamInfo? ->
+ ShareUtils.openUrlInBrowser(
+ requireContext(),
+ info!!.getUrl()
+ )
+ }
+ )
+ )
+ binding!!.detailControlsPlayWithKodi.setOnClickListener(
+ makeOnClickListener(
+ Consumer { info: StreamInfo? ->
+ KoreUtils.playWithKore(
+ requireContext(),
+ Uri.parse(
+ info!!.getUrl()
+ )
+ )
+ }
+ )
+ )
+ if (DEBUG) {
+ binding!!.detailControlsCrashThePlayer.setOnClickListener(
+ View.OnClickListener { v: View? ->
+ VideoDetailPlayerCrasher.onCrashThePlayer(
+ requireContext(),
+ player
+ )
+ }
+ )
+ }
+
+ val overlayListener = View.OnClickListener { v: View? ->
+ bottomSheetBehavior!!
+ .setState(BottomSheetBehavior.STATE_EXPANDED)
+ }
+ binding!!.overlayThumbnail.setOnClickListener(overlayListener)
+ binding!!.overlayMetadataLayout.setOnClickListener(overlayListener)
+ binding!!.overlayButtonsLayout.setOnClickListener(overlayListener)
+ binding!!.overlayCloseButton.setOnClickListener(
+ View.OnClickListener { v: View? ->
+ bottomSheetBehavior!!
+ .setState(BottomSheetBehavior.STATE_HIDDEN)
+ }
+ )
+ binding!!.overlayPlayQueueButton.setOnClickListener(
+ View.OnClickListener { v: View? ->
+ NavigationHelper.openPlayQueue(
+ requireContext()
+ )
+ }
+ )
+ binding!!.overlayPlayPauseButton.setOnClickListener(
+ View.OnClickListener { v: View? ->
+ if (playerIsNotStopped()) {
+ player!!.playPause()
+ player!!.UIs().getOpt(VideoPlayerUi::class.java)
+ .ifPresent(Consumer { ui: VideoPlayerUi? -> ui!!.hideControls(0, 0) })
+ showSystemUi()
+ } else {
+ autoPlayEnabled = true // forcefully start playing
+ openVideoPlayer(false)
+ }
+ setOverlayPlayPauseImage(this.isPlayerAvailable && player!!.isPlaying())
+ }
+ )
+ }
+
+ private fun makeOnClickListener(consumer: Consumer): View.OnClickListener {
+ return View.OnClickListener { v: View? ->
+ if (!isLoading.get() && currentInfo != null) {
+ consumer.accept(currentInfo)
+ }
+ }
+ }
+
+ private fun setOnLongClickListeners() {
+ binding!!.detailTitleRootLayout.setOnLongClickListener(
+ makeOnLongClickListener(
+ Consumer { info: StreamInfo? ->
+ ShareUtils.copyToClipboard(
+ requireContext(),
+ binding!!.detailVideoTitleView.getText().toString()
+ )
+ }
+ )
+ )
+ binding!!.detailUploaderRootLayout.setOnLongClickListener(
+ makeOnLongClickListener(
+ Consumer { info: StreamInfo? ->
+ if (TextUtils.isEmpty(
+ info!!.getSubChannelUrl()
+ )
+ ) {
+ Log.w(TAG, "Can't open parent channel because we got no parent channel URL")
+ } else {
+ openChannel(info.getUploaderUrl(), info.getUploaderName())
+ }
+ }
+ )
+ )
+
+ binding!!.detailControlsBackground.setOnLongClickListener(
+ makeOnLongClickListener(
+ Consumer { info: StreamInfo? ->
+ openBackgroundPlayer(
+ true
+ )
+ }
+ )
+ )
+ binding!!.detailControlsPopup.setOnLongClickListener(
+ makeOnLongClickListener(
+ Consumer { info: StreamInfo? ->
+ openPopupPlayer(
+ true
+ )
+ }
+ )
+ )
+ binding!!.detailControlsDownload.setOnLongClickListener(
+ makeOnLongClickListener(
+ Consumer { info: StreamInfo? ->
+ NavigationHelper.openDownloads(
+ activity
+ )
+ }
+ )
+ )
+
+ val overlayListener = makeOnLongClickListener(
+ Consumer { info: StreamInfo? ->
+ openChannel(
+ info!!.getUploaderUrl(), info.getUploaderName()
+ )
+ }
+ )
+ binding!!.overlayThumbnail.setOnLongClickListener(overlayListener)
+ binding!!.overlayMetadataLayout.setOnLongClickListener(overlayListener)
+ }
+
+ private fun makeOnLongClickListener(consumer: Consumer): OnLongClickListener {
+ return OnLongClickListener { v: View? ->
+ if (isLoading.get() || currentInfo == null) {
+ return@OnLongClickListener false
+ }
+ consumer.accept(currentInfo)
+ true
+ }
+ }
+
+ private fun openChannel(subChannelUrl: String?, subChannelName: String) {
+ try {
+ NavigationHelper.openChannelFragment(
+ getFM(), currentInfo!!.getServiceId(),
+ subChannelUrl, subChannelName
+ )
+ } catch (e: Exception) {
+ showUiErrorSnackbar(this, "Opening channel fragment", e)
+ }
+ }
+
+ private fun toggleTitleAndSecondaryControls() {
+ if (binding!!.detailSecondaryControlPanel.getVisibility() == View.GONE) {
+ binding!!.detailVideoTitleView.setMaxLines(10)
+ binding!!.detailToggleSecondaryControlsView
+ .animateRotation(VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 180)
+ binding!!.detailSecondaryControlPanel.setVisibility(View.VISIBLE)
+ } else {
+ binding!!.detailVideoTitleView.setMaxLines(1)
+ binding!!.detailToggleSecondaryControlsView
+ .animateRotation(VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 0)
+ binding!!.detailSecondaryControlPanel.setVisibility(View.GONE)
+ }
+ // view pager height has changed, update the tab layout
+ updateTabLayoutVisibility()
+ }
+
+ /*//////////////////////////////////////////////////////////////////////////
+ // Init
+ ////////////////////////////////////////////////////////////////////////// */
+ // called from onViewCreated in {@link BaseFragment#onViewCreated}
+ override fun initViews(rootView: View?, savedInstanceState: Bundle?) {
+ super.initViews(rootView, savedInstanceState)
+
+ pageAdapter = TabAdapter(getChildFragmentManager())
+ binding!!.viewPager.setAdapter(pageAdapter)
+ binding!!.tabLayout.setupWithViewPager(binding!!.viewPager)
+
+ binding!!.detailThumbnailRootLayout.requestFocus()
+
+ binding!!.detailControlsPlayWithKodi.setVisibility(
+ if (KoreUtils.shouldShowPlayWithKodi(requireContext(), serviceId))
+ View.VISIBLE
+ else
+ View.GONE
+ )
+ binding!!.detailControlsCrashThePlayer.setVisibility(
+ if (DEBUG && PreferenceManager.getDefaultSharedPreferences(getContext()!!)
+ .getBoolean(getString(R.string.show_crash_the_player_key), false)
+ )
+ View.VISIBLE
+ else
+ View.GONE
+ )
+ accommodateForTvAndDesktopMode()
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ override fun initListeners() {
+ super.initListeners()
+
+ setOnClickListeners()
+ setOnLongClickListeners()
+
+ val controlsTouchListener = OnTouchListener { view: View?, motionEvent: MotionEvent? ->
+ if (motionEvent!!.getAction() == MotionEvent.ACTION_DOWN &&
+ PlayButtonHelper.shouldShowHoldToAppendTip(activity)
+ ) {
+ binding!!.touchAppendDetail.animate(
+ true,
+ 250,
+ AnimationType.ALPHA,
+ 0,
+ Runnable {
+ binding!!.touchAppendDetail.animate(
+ false,
+ 1500,
+ AnimationType.ALPHA,
+ 1000
+ )
+ }
+ )
+ }
+ false
+ }
+ binding!!.detailControlsBackground.setOnTouchListener(controlsTouchListener)
+ binding!!.detailControlsPopup.setOnTouchListener(controlsTouchListener)
+
+ binding!!.appBarLayout.addOnOffsetChangedListener(
+ OnOffsetChangedListener { layout: AppBarLayout?, verticalOffset: Int ->
+ // prevent useless updates to tab layout visibility if nothing changed
+ if (verticalOffset != lastAppBarVerticalOffset) {
+ lastAppBarVerticalOffset = verticalOffset
+ // the view was scrolled
+ updateTabLayoutVisibility()
+ }
+ }
+ )
+
+ setupBottomPlayer()
+ if (!playerHolder.isBound) {
+ setHeightThumbnail()
+ } else {
+ playerHolder.startService(false, this)
+ }
+ }
+
+ override fun onKeyDown(keyCode: Int): Boolean {
+ return this.isPlayerAvailable &&
+ player!!.UIs().getOpt(VideoPlayerUi::class.java)
+ .map(Function { playerUi: VideoPlayerUi? -> playerUi!!.onKeyDown(keyCode) })
+ .orElse(false)
+ }
+
+ override fun onBackPressed(): Boolean {
+ if (DEBUG) {
+ Log.d(TAG, "onBackPressed() called")
+ }
+
+ // If we are in fullscreen mode just exit from it via first back press
+ if (this.isFullscreen) {
+ if (!DeviceUtils.isTablet(activity)) {
+ player!!.pause()
+ }
+ restoreDefaultOrientation()
+ setAutoPlay(false)
+ return true
+ }
+
+ // If we have something in history of played items we replay it here
+ if (this.isPlayerAvailable &&
+ player!!.getPlayQueue() != null && player!!.videoPlayerSelected() &&
+ player!!.getPlayQueue()!!.previous()
+ ) {
+ return true // no code here, as previous() was used in the if
+ }
+
+ // That means that we are on the start of the stack,
+ if (stack.size <= 1) {
+ restoreDefaultOrientation()
+ return false // let MainActivity handle the onBack (e.g. to minimize the mini player)
+ }
+
+ // Remove top
+ stack.pop()
+ // Get stack item from the new top
+ setupFromHistoryItem(Objects.requireNonNull(stack.peek()))
+
+ return true
+ }
+
+ private fun setupFromHistoryItem(item: StackItem) {
+ setAutoPlay(false)
+ hideMainPlayerOnLoadingNewStream()
+
+ setInitialData(
+ item.getServiceId(), item.getUrl(),
+ if (item.getTitle() == null) "" else item.getTitle(), item.getPlayQueue()
+ )
+ startLoading(false)
+
+ // Maybe an item was deleted in background activity
+ if (item.getPlayQueue().getItem() == null) {
+ return
+ }
+
+ val playQueueItem = item.getPlayQueue().getItem()
+ // Update title, url, uploader from the last item in the stack (it's current now)
+ val isPlayerStopped = !this.isPlayerAvailable || player!!.isStopped()
+ if (playQueueItem != null && isPlayerStopped) {
+ updateOverlayData(
+ playQueueItem.getTitle(),
+ playQueueItem.getUploader(), playQueueItem.getThumbnails()
+ )
+ }
+ }
+
+ /*//////////////////////////////////////////////////////////////////////////
+ // Info loading and handling
+ ////////////////////////////////////////////////////////////////////////// */
+ override fun doInitialLoadLogic() {
+ if (wasCleared()) {
+ return
+ }
+
+ if (currentInfo == null) {
+ prepareAndLoadInfo()
+ } else {
+ prepareAndHandleInfoIfNeededAfterDelay(currentInfo!!, false, 50)
+ }
+ }
+
+ fun selectAndLoadVideo(
+ newServiceId: Int,
+ newUrl: String?,
+ newTitle: String,
+ newQueue: PlayQueue?
+ ) {
+ if (this.isPlayerAvailable && newQueue != null && playQueue != null && playQueue!!.getItem() != null && (
+ playQueue!!.getItem()!!
+ .getUrl() != newUrl
+ )
+ ) {
+ // Preloading can be disabled since playback is surely being replaced.
+ player!!.disablePreloadingOfCurrentTrack()
+ }
+
+ setInitialData(newServiceId, newUrl, newTitle, newQueue)
+ startLoading(false, true)
+ }
+
+ private fun prepareAndHandleInfoIfNeededAfterDelay(
+ info: StreamInfo,
+ scrollToTop: Boolean,
+ delay: Long
+ ) {
+ Handler(Looper.getMainLooper()).postDelayed(
+ Runnable {
+ if (activity == null) {
+ return@postDelayed
+ }
+ // Data can already be drawn, don't spend time twice
+ if (info.getName() == binding!!.detailVideoTitleView.getText().toString()) {
+ return@postDelayed
+ }
+ prepareAndHandleInfo(info, scrollToTop)
+ },
+ delay
+ )
+ }
+
+ private fun prepareAndHandleInfo(info: StreamInfo, scrollToTop: Boolean) {
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ (
+ "prepareAndHandleInfo() called with: " +
+ "info = [" + info + "], scrollToTop = [" + scrollToTop + "]"
+ )
+ )
+ }
+
+ showLoading()
+ initTabs()
+
+ if (scrollToTop) {
+ scrollToTop()
+ }
+ handleResult(info)
+ showContent()
+ }
+
+ private fun prepareAndLoadInfo() {
+ scrollToTop()
+ startLoading(false)
+ }
+
+ public override fun startLoading(forceLoad: Boolean) {
+ startLoading(forceLoad, null)
+ }
+
+ private fun startLoading(forceLoad: Boolean, addToBackStack: Boolean?) {
+ super.startLoading(forceLoad)
+
+ initTabs()
+ currentInfo = null
+ if (currentWorker != null) {
+ currentWorker!!.dispose()
+ }
+
+ runWorker(forceLoad, if (addToBackStack != null) addToBackStack else stack.isEmpty())
+ }
+
+ private fun runWorker(forceLoad: Boolean, addToBackStack: Boolean) {
+ val prefs = PreferenceManager.getDefaultSharedPreferences(activity)
+ currentWorker = ExtractorHelper.getStreamInfo(serviceId, url, forceLoad)
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ io.reactivex.rxjava3.functions.Consumer { result: StreamInfo? ->
+ isLoading.set(false)
+ hideMainPlayerOnLoadingNewStream()
+ if (result!!.getAgeLimit() != StreamExtractor.NO_AGE_LIMIT && !prefs.getBoolean(
+ getString(R.string.show_age_restricted_content), false
+ )
+ ) {
+ hideAgeRestrictedContent()
+ } else {
+ handleResult(result)
+ showContent()
+ if (addToBackStack) {
+ if (playQueue == null) {
+ playQueue = SinglePlayQueue(result)
+ }
+ if (stack.isEmpty() || stack.peek()!!.getPlayQueue() != playQueue) {
+ stack.push(StackItem(serviceId, url, title, playQueue))
+ }
+ }
+
+ if (this.isAutoplayEnabled) {
+ openVideoPlayerAutoFullscreen()
+ }
+ }
+ },
+ io.reactivex.rxjava3.functions.Consumer { throwable: Throwable? ->
+ showError(
+ ErrorInfo(
+ throwable!!, UserAction.REQUESTED_STREAM,
+ (if (url == null) "no url" else url)!!, serviceId
+ )
+ )
+ }
+ )
+ }
+
+ /*//////////////////////////////////////////////////////////////////////////
+ // Tabs
+ ////////////////////////////////////////////////////////////////////////// */
+ private fun initTabs() {
+ if (pageAdapter!!.getCount() != 0) {
+ selectedTabTag = pageAdapter!!.getItemTitle(binding!!.viewPager.getCurrentItem())
+ }
+ pageAdapter!!.clearAllItems()
+ tabIcons.clear()
+ tabContentDescriptions.clear()
+
+ if (shouldShowComments()) {
+ pageAdapter!!.addFragment(getInstance(serviceId, url), COMMENTS_TAB_TAG)
+ tabIcons.add(R.drawable.ic_comment)
+ tabContentDescriptions.add(R.string.comments_tab_description)
+ }
+
+ if (showRelatedItems && binding!!.relatedItemsLayout == null) {
+ // temp empty fragment. will be updated in handleResult
+ pageAdapter!!.addFragment(EmptyFragment.newInstance(false), RELATED_TAB_TAG)
+ tabIcons.add(R.drawable.ic_art_track)
+ tabContentDescriptions.add(R.string.related_items_tab_description)
+ }
+
+ if (showDescription) {
+ // temp empty fragment. will be updated in handleResult
+ pageAdapter!!.addFragment(EmptyFragment.newInstance(false), DESCRIPTION_TAB_TAG)
+ tabIcons.add(R.drawable.ic_description)
+ tabContentDescriptions.add(R.string.description_tab_description)
+ }
+
+ if (pageAdapter!!.getCount() == 0) {
+ pageAdapter!!.addFragment(EmptyFragment.newInstance(true), EMPTY_TAB_TAG)
+ }
+ pageAdapter!!.notifyDataSetUpdate()
+
+ if (pageAdapter!!.getCount() >= 2) {
+ val position = pageAdapter!!.getItemPositionByTitle(selectedTabTag)
+ if (position != -1) {
+ binding!!.viewPager.setCurrentItem(position)
+ }
+ updateTabIconsAndContentDescriptions()
+ }
+ // the page adapter now contains tabs: show the tab layout
+ updateTabLayoutVisibility()
+ }
+
+ /**
+ * To be called whenever [.pageAdapter] is modified, since that triggers a refresh in
+ * [FragmentVideoDetailBinding.tabLayout] resetting all tab's icons and content
+ * descriptions. This reads icons from [.tabIcons] and content descriptions from
+ * [.tabContentDescriptions], which are all set in [.initTabs].
+ */
+ private fun updateTabIconsAndContentDescriptions() {
+ for (i in tabIcons.indices) {
+ val tab = binding!!.tabLayout.getTabAt(i)
+ if (tab != null) {
+ tab.setIcon(tabIcons.get(i)!!)
+ tab.setContentDescription(tabContentDescriptions.get(i)!!)
+ }
+ }
+ }
+
+ private fun updateTabs(info: StreamInfo) {
+ if (showRelatedItems) {
+ if (binding!!.relatedItemsLayout == null) { // phone
+ pageAdapter!!.updateItem(RELATED_TAB_TAG, getInstance(info))
+ } else { // tablet + TV
+ getChildFragmentManager().beginTransaction()
+ .replace(R.id.relatedItemsLayout, getInstance(info))
+ .commitAllowingStateLoss()
+ binding!!.relatedItemsLayout!!.setVisibility(if (this.isFullscreen) View.GONE else View.VISIBLE)
+ }
+ }
+
+ if (showDescription) {
+ pageAdapter!!.updateItem(DESCRIPTION_TAB_TAG, DescriptionFragment(info))
+ }
+
+ binding!!.viewPager.setVisibility(View.VISIBLE)
+ // make sure the tab layout is visible
+ updateTabLayoutVisibility()
+ pageAdapter!!.notifyDataSetUpdate()
+ updateTabIconsAndContentDescriptions()
+ }
+
+ private fun shouldShowComments(): Boolean {
+ try {
+ return showComments && NewPipe.getService(serviceId)
+ .getServiceInfo()
+ .getMediaCapabilities()
+ .contains(MediaCapability.COMMENTS)
+ } catch (e: ExtractionException) {
+ return false
+ }
+ }
+
+ fun updateTabLayoutVisibility() {
+ if (binding == null) {
+ // If binding is null we do not need to and should not do anything with its object(s)
+ return
+ }
+
+ if (pageAdapter!!.getCount() < 2 || binding!!.viewPager.getVisibility() != View.VISIBLE) {
+ // hide tab layout if there is only one tab or if the view pager is also hidden
+ binding!!.tabLayout.setVisibility(View.GONE)
+ } else {
+ // call `post()` to be sure `viewPager.getHitRect()`
+ // is up to date and not being currently recomputed
+ binding!!.tabLayout.post(
+ Runnable {
+ val activity = getActivity()
+ if (activity != null) {
+ val pagerHitRect = Rect()
+ binding!!.viewPager.getHitRect(pagerHitRect)
+
+ val height = DeviceUtils.getWindowHeight(activity.getWindowManager())
+ val viewPagerVisibleHeight = height - pagerHitRect.top
+ // see TabLayout.DEFAULT_HEIGHT, which is equal to 48dp
+ val tabLayoutHeight = TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP, 48f, getResources().getDisplayMetrics()
+ )
+
+ if (viewPagerVisibleHeight > tabLayoutHeight * 2) {
+ // no translation at all when viewPagerVisibleHeight > tabLayout.height * 3
+ binding!!.tabLayout.setTranslationY(
+ max(
+ 0.0,
+ (tabLayoutHeight * 3 - viewPagerVisibleHeight).toDouble()
+ ).toFloat()
+ )
+ binding!!.tabLayout.setVisibility(View.VISIBLE)
+ } else {
+ // view pager is not visible enough
+ binding!!.tabLayout.setVisibility(View.GONE)
+ }
+ }
+ }
+ )
+ }
+ }
+
+ fun scrollToTop() {
+ binding!!.appBarLayout.setExpanded(true, true)
+ // notify tab layout of scrolling
+ updateTabLayoutVisibility()
+ }
+
+ /*//////////////////////////////////////////////////////////////////////////
+ // Play Utils
+ ////////////////////////////////////////////////////////////////////////// */
+ private fun toggleFullscreenIfInFullscreenMode() {
+ // If a user watched video inside fullscreen mode and than chose another player
+ // return to non-fullscreen mode
+ if (this.isPlayerAvailable) {
+ player!!.UIs().getOpt(MainPlayerUi::class.java)
+ .ifPresent(
+ Consumer { playerUi: MainPlayerUi? ->
+ if (playerUi!!.isFullscreen()) {
+ playerUi.toggleFullscreen()
+ }
+ }
+ )
+ }
+ }
+
+ private fun openBackgroundPlayer(append: Boolean) {
+ val useExternalAudioPlayer = PreferenceManager
+ .getDefaultSharedPreferences(activity)
+ .getBoolean(activity.getString(R.string.use_external_audio_player_key), false)
+
+ toggleFullscreenIfInFullscreenMode()
+
+ if (this.isPlayerAvailable) {
+ // FIXME Workaround #7427
+ player!!.setRecovery()
+ }
+
+ if (useExternalAudioPlayer) {
+ showExternalAudioPlaybackDialog()
+ } else {
+ openNormalBackgroundPlayer(append)
+ }
+ }
+
+ private fun openPopupPlayer(append: Boolean) {
+ if (!PermissionHelper.isPopupEnabledElseAsk(activity)) {
+ return
+ }
+
+ // See UI changes while remote playQueue changes
+ if (!this.isPlayerAvailable) {
+ playerHolder.startService(false, this)
+ } else {
+ // FIXME Workaround #7427
+ player!!.setRecovery()
+ }
+
+ toggleFullscreenIfInFullscreenMode()
+
+ val queue = setupPlayQueueForIntent(append)
+ if (append) { // resumePlayback: false
+ NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.POPUP)
+ } else {
+ replaceQueueIfUserConfirms(
+ Runnable {
+ NavigationHelper
+ .playOnPopupPlayer(activity, queue, true)
+ }
+ )
+ }
+ }
+
+ /**
+ * Opens the video player, in fullscreen if needed. In order to open fullscreen, the activity
+ * is toggled to landscape orientation (which will then cause fullscreen mode).
+ *
+ * @param directlyFullscreenIfApplicable whether to open fullscreen if we are not already
+ * in landscape and screen orientation is locked
+ */
+ fun openVideoPlayer(directlyFullscreenIfApplicable: Boolean) {
+ if (directlyFullscreenIfApplicable &&
+ !DeviceUtils.isLandscape(requireContext()) && PlayerHelper.globalScreenOrientationLocked(
+ requireContext()
+ )
+ ) {
+ // Make sure the bottom sheet turns out expanded. When this code kicks in the bottom
+ // sheet could not have fully expanded yet, and thus be in the STATE_SETTLING state.
+ // When the activity is rotated, and its state is saved and then restored, the bottom
+ // sheet would forget what it was doing, since even if STATE_SETTLING is restored, it
+ // doesn't tell which state it was settling to, and thus the bottom sheet settles to
+ // STATE_COLLAPSED. This can be solved by manually setting the state that will be
+ // restored (i.e. bottomSheetState) to STATE_EXPANDED.
+ updateBottomSheetState(BottomSheetBehavior.STATE_EXPANDED)
+ // toggle landscape in order to open directly in fullscreen
+ onScreenRotationButtonClicked()
+ }
+
+ if (PreferenceManager.getDefaultSharedPreferences(activity)
+ .getBoolean(this.getString(R.string.use_external_video_player_key), false)
+ ) {
+ showExternalVideoPlaybackDialog()
+ } else {
+ replaceQueueIfUserConfirms(Runnable { this.openMainPlayer() })
+ }
+ }
+
+ /**
+ * If the option to start directly fullscreen is enabled, calls
+ * [.openVideoPlayer] with `directlyFullscreenIfApplicable = true`, so that
+ * if the user is not already in landscape and he has screen orientation locked the activity
+ * rotates and fullscreen starts. Otherwise, if the option to start directly fullscreen is
+ * disabled, calls [.openVideoPlayer] with `directlyFullscreenIfApplicable
+ * = false`, hence preventing it from going directly fullscreen.
+ */
+ fun openVideoPlayerAutoFullscreen() {
+ openVideoPlayer(PlayerHelper.isStartMainPlayerFullscreenEnabled(requireContext()))
+ }
+
+ private fun openNormalBackgroundPlayer(append: Boolean) {
+ // See UI changes while remote playQueue changes
+ if (!this.isPlayerAvailable) {
+ playerHolder.startService(false, this)
+ }
+
+ val queue = setupPlayQueueForIntent(append)
+ if (append) {
+ NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.AUDIO)
+ } else {
+ replaceQueueIfUserConfirms(
+ Runnable {
+ NavigationHelper
+ .playOnBackgroundPlayer(activity, queue, true)
+ }
+ )
+ }
+ }
+
+ private fun openMainPlayer() {
+ if (noPlayerServiceAvailable()) {
+ playerHolder.startService(autoPlayEnabled, this)
+ return
+ }
+ if (currentInfo == null) {
+ return
+ }
+
+ val queue = setupPlayQueueForIntent(false)
+ tryAddVideoPlayerView()
+
+ val playerIntent = NavigationHelper.getPlayerIntent(
+ requireContext(),
+ PlayerService::class.java, queue, true, autoPlayEnabled
+ )
+ ContextCompat.startForegroundService(activity, playerIntent)
+ }
+
+ /**
+ * When the video detail fragment is already showing details for a video and the user opens a
+ * new one, the video detail fragment changes all of its old data to the new stream, so if there
+ * is a video player currently open it should be hidden. This method does exactly that. If
+ * autoplay is enabled, the underlying player is not stopped completely, since it is going to
+ * be reused in a few milliseconds and the flickering would be annoying.
+ */
+ private fun hideMainPlayerOnLoadingNewStream() {
+ val root = this.root
+ if (noPlayerServiceAvailable() || root.isEmpty() || !player!!.videoPlayerSelected()) {
+ return
+ }
+
+ removeVideoPlayerView()
+ if (this.isAutoplayEnabled) {
+ playerService!!.stopForImmediateReusing()
+ root.ifPresent(Consumer { view: View? -> view!!.setVisibility(View.GONE) })
+ } else {
+ playerHolder.stopService()
+ }
+ }
+
+ private fun setupPlayQueueForIntent(append: Boolean): PlayQueue {
+ if (append) {
+ return SinglePlayQueue(currentInfo)
+ }
+
+ var queue = playQueue
+ // Size can be 0 because queue removes bad stream automatically when error occurs
+ if (queue == null || queue.isEmpty()) {
+ queue = SinglePlayQueue(currentInfo)
+ }
+
+ return queue
+ }
+
+ /*//////////////////////////////////////////////////////////////////////////
+ // Utils
+ ////////////////////////////////////////////////////////////////////////// */
+ fun setAutoPlay(autoPlay: Boolean) {
+ this.autoPlayEnabled = autoPlay
+ }
+
+ private fun startOnExternalPlayer(
+ context: Context,
+ info: StreamInfo,
+ selectedStream: Stream
+ ) {
+ NavigationHelper.playOnExternalPlayer(
+ context, currentInfo!!.getName(),
+ currentInfo!!.getSubChannelName(), selectedStream
+ )
+
+ val recordManager = HistoryRecordManager(requireContext())
+ disposables.add(
+ recordManager.onViewed(info).onErrorComplete()
+ .subscribe(
+ io.reactivex.rxjava3.functions.Consumer { ignored: Long? -> },
+ io.reactivex.rxjava3.functions.Consumer { error: Throwable? ->
+ Log.e(
+ TAG,
+ "Register view failure: ",
+ error
+ )
+ }
+ )
+ )
+ }
+
+ private val isExternalPlayerEnabled: Boolean
+ get() = PreferenceManager.getDefaultSharedPreferences(requireContext())
+ .getBoolean(getString(R.string.use_external_video_player_key), false)
+
+ private val isAutoplayEnabled: Boolean
+ // This method overrides default behaviour when setAutoPlay() is called.
+ get() = autoPlayEnabled &&
+ !this.isExternalPlayerEnabled && (!this.isPlayerAvailable || player!!.videoPlayerSelected()) &&
+ bottomSheetState != BottomSheetBehavior.STATE_HIDDEN && PlayerHelper.isAutoplayAllowedByUser(
+ requireContext()
+ )
+
+ private fun tryAddVideoPlayerView() {
+ if (this.isPlayerAvailable && getView() != null) {
+ // Setup the surface view height, so that it fits the video correctly; this is done also
+ // here, and not only in the Handler, to avoid a choppy fullscreen rotation animation.
+ setHeightThumbnail()
+ }
+
+ // do all the null checks in the posted lambda, too, since the player, the binding and the
+ // view could be set or unset before the lambda gets executed on the next main thread cycle
+ Handler(Looper.getMainLooper()).post(
+ Runnable {
+ if (!this.isPlayerAvailable || getView() == null) {
+ return@post
+ }
+ // setup the surface view height, so that it fits the video correctly
+ setHeightThumbnail()
+ player!!.UIs().getOpt(MainPlayerUi::class.java)
+ .ifPresent(
+ Consumer { playerUi: MainPlayerUi? ->
+ // sometimes binding would be null here, even though getView() != null above u.u
+ if (binding != null) {
+ // prevent from re-adding a view multiple times
+ playerUi!!.removeViewFromParent()
+ binding!!.playerPlaceholder.addView(playerUi.getBinding().getRoot())
+ playerUi.setupVideoSurfaceIfNeeded()
+ }
+ }
+ )
+ }
+ )
+ }
+
+ private fun removeVideoPlayerView() {
+ makeDefaultHeightForVideoPlaceholder()
+
+ if (player != null) {
+ player!!.UIs().getOpt(VideoPlayerUi::class.java)
+ .ifPresent(Consumer { obj: VideoPlayerUi? -> obj!!.removeViewFromParent() })
+ }
+ }
+
+ private fun makeDefaultHeightForVideoPlaceholder() {
+ if (getView() == null) {
+ return
+ }
+
+ binding!!.playerPlaceholder.getLayoutParams().height = FrameLayout.LayoutParams.MATCH_PARENT
+ binding!!.playerPlaceholder.requestLayout()
+ }
+
+ private val preDrawListener: ViewTreeObserver.OnPreDrawListener =
+ object : ViewTreeObserver.OnPreDrawListener {
+ override fun onPreDraw(): Boolean {
+ val metrics = getResources().getDisplayMetrics()
+
+ if (getView() != null) {
+ val height = (
+ if (DeviceUtils.isInMultiWindow(activity))
+ requireView()
+ else
+ activity.getWindow().getDecorView()
+ ).getHeight()
+ setHeightThumbnail(height, metrics)
+ getView()!!.getViewTreeObserver().removeOnPreDrawListener(preDrawListener)
+ }
+ return false
+ }
+ }
+
+ /**
+ * Method which controls the size of thumbnail and the size of main player inside
+ * a layout with thumbnail. It decides what height the player should have in both
+ * screen orientations. It knows about multiWindow feature
+ * and about videos with aspectRatio ZOOM (the height for them will be a bit higher,
+ * [.MAX_PLAYER_HEIGHT])
+ */
+ private fun setHeightThumbnail() {
+ val metrics = getResources().getDisplayMetrics()
+ val isPortrait = metrics.heightPixels > metrics.widthPixels
+ requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener)
+
+ if (this.isFullscreen) {
+ val height = (
+ if (DeviceUtils.isInMultiWindow(activity))
+ requireView()
+ else
+ activity.getWindow().getDecorView()
+ ).getHeight()
+ // Height is zero when the view is not yet displayed like after orientation change
+ if (height != 0) {
+ setHeightThumbnail(height, metrics)
+ } else {
+ requireView().getViewTreeObserver().addOnPreDrawListener(preDrawListener)
+ }
+ } else {
+ val height = (
+ if (isPortrait)
+ metrics.widthPixels / (16.0f / 9.0f)
+ else
+ metrics.heightPixels / 2.0f
+ ).toInt()
+ setHeightThumbnail(height, metrics)
+ }
+ }
+
+ private fun setHeightThumbnail(newHeight: Int, metrics: DisplayMetrics) {
+ binding!!.detailThumbnailImageView.setLayoutParams(
+ FrameLayout.LayoutParams(
+ RelativeLayout.LayoutParams.MATCH_PARENT, newHeight
+ )
+ )
+ binding!!.detailThumbnailImageView.setMinimumHeight(newHeight)
+ if (this.isPlayerAvailable) {
+ val maxHeight = (metrics.heightPixels * MAX_PLAYER_HEIGHT).toInt()
+ player!!.UIs().getOpt(VideoPlayerUi::class.java)
+ .ifPresent(
+ Consumer { ui: VideoPlayerUi? ->
+ ui!!.getBinding().surfaceView.setHeights(
+ newHeight,
+ if (ui.isFullscreen()) newHeight else maxHeight
+ )
+ }
+ )
+ }
+ }
+
+ private fun showContent() {
+ binding!!.detailContentRootHiding.setVisibility(View.VISIBLE)
+ }
+
+ private fun setInitialData(
+ newServiceId: Int,
+ newUrl: String?,
+ newTitle: String,
+ newPlayQueue: PlayQueue?
+ ) {
+ this.serviceId = newServiceId
+ this.url = newUrl
+ this.title = newTitle
+ this.playQueue = newPlayQueue
+ }
+
+ private fun setErrorImage() {
+ if (binding == null || activity == null) {
+ return
+ }
+
+ binding!!.detailThumbnailImageView.setImageDrawable(
+ AppCompatResources.getDrawable(requireContext(), R.drawable.not_available_monkey)
+ )
+ binding!!.detailThumbnailImageView.animate(
+ false, 0, AnimationType.ALPHA,
+ 0, Runnable { binding!!.detailThumbnailImageView.animate(true, 500) }
+ )
+ }
+
+ override fun handleError() {
+ super.handleError()
+ setErrorImage()
+
+ if (binding!!.relatedItemsLayout != null) { // hide related streams for tablets
+ binding!!.relatedItemsLayout!!.setVisibility(View.INVISIBLE)
+ }
+
+ // hide comments / related streams / description tabs
+ binding!!.viewPager.setVisibility(View.GONE)
+ binding!!.tabLayout.setVisibility(View.GONE)
+ }
+
+ private fun hideAgeRestrictedContent() {
+ showTextError(
+ getString(
+ R.string.restricted_video,
+ getString(R.string.show_age_restricted_content_title)
+ )
+ )
+ }
+
+ private fun setupBroadcastReceiver() {
+ broadcastReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent) {
+ when (intent.getAction()) {
+ ACTION_SHOW_MAIN_PLAYER -> bottomSheetBehavior!!.setState(BottomSheetBehavior.STATE_EXPANDED)
+ ACTION_HIDE_MAIN_PLAYER -> bottomSheetBehavior!!.setState(BottomSheetBehavior.STATE_HIDDEN)
+ ACTION_PLAYER_STARTED -> {
+ // If the state is not hidden we don't need to show the mini player
+ if (bottomSheetBehavior!!.getState() == BottomSheetBehavior.STATE_HIDDEN) {
+ bottomSheetBehavior!!.setState(BottomSheetBehavior.STATE_COLLAPSED)
+ }
+ // Rebound to the service if it was closed via notification or mini player
+ if (!playerHolder.isBound) {
+ playerHolder.startService(
+ false, this@VideoDetailFragment
+ )
+ }
+ }
+ }
+ }
+ }
+ val intentFilter = IntentFilter()
+ intentFilter.addAction(ACTION_SHOW_MAIN_PLAYER)
+ intentFilter.addAction(ACTION_HIDE_MAIN_PLAYER)
+ intentFilter.addAction(ACTION_PLAYER_STARTED)
+ activity.registerReceiver(broadcastReceiver, intentFilter)
+ }
+
+ /*//////////////////////////////////////////////////////////////////////////
+ // Orientation listener
+ ////////////////////////////////////////////////////////////////////////// */
+ private fun restoreDefaultOrientation() {
+ if (this.isPlayerAvailable && player!!.videoPlayerSelected()) {
+ toggleFullscreenIfInFullscreenMode()
+ }
+
+ // This will show systemUI and pause the player.
+ // User can tap on Play button and video will be in fullscreen mode again
+ // Note for tablet: trying to avoid orientation changes since it's not easy
+ // to physically rotate the tablet every time
+ if (activity != null && !DeviceUtils.isTablet(activity)) {
+ activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED)
+ }
+ }
+
+ /*//////////////////////////////////////////////////////////////////////////
+ // Contract
+ ////////////////////////////////////////////////////////////////////////// */
+ override fun showLoading() {
+ super.showLoading()
+
+ // if data is already cached, transition from VISIBLE -> INVISIBLE -> VISIBLE is not required
+ if (!ExtractorHelper.isCached(serviceId, url!!, InfoCache.Type.STREAM)) {
+ binding!!.detailContentRootHiding.setVisibility(View.INVISIBLE)
+ }
+
+ binding!!.detailThumbnailPlayButton.animate(false, 50)
+ binding!!.detailDurationView.animate(false, 100)
+ binding!!.detailPositionView.setVisibility(View.GONE)
+ binding!!.positionView.setVisibility(View.GONE)
+
+ binding!!.detailVideoTitleView.setText(title)
+ binding!!.detailVideoTitleView.setMaxLines(1)
+ binding!!.detailVideoTitleView.animate(true, 0)
+
+ binding!!.detailToggleSecondaryControlsView.setVisibility(View.GONE)
+ binding!!.detailTitleRootLayout.setClickable(false)
+ binding!!.detailSecondaryControlPanel.setVisibility(View.GONE)
+
+ if (binding!!.relatedItemsLayout != null) {
+ if (showRelatedItems) {
+ binding!!.relatedItemsLayout!!.setVisibility(
+ if (this.isFullscreen) View.GONE else View.INVISIBLE
+ )
+ } else {
+ binding!!.relatedItemsLayout!!.setVisibility(View.GONE)
+ }
+ }
+
+ dispose(binding!!.detailThumbnailImageView)
+ dispose(binding!!.detailSubChannelThumbnailView)
+ dispose(binding!!.overlayThumbnail)
+ dispose(binding!!.detailUploaderThumbnailView)
+
+ binding!!.detailThumbnailImageView.setImageBitmap(null)
+ binding!!.detailSubChannelThumbnailView.setImageBitmap(null)
+ }
+
+ override fun handleResult(info: StreamInfo) {
+ super.handleResult(info)
+
+ currentInfo = info
+ setInitialData(info.getServiceId(), info.getOriginalUrl(), info.getName(), playQueue)
+
+ updateTabs(info)
+
+ binding!!.detailThumbnailPlayButton.animate(true, 200)
+ binding!!.detailVideoTitleView.setText(title)
+
+ binding!!.detailSubChannelThumbnailView.setVisibility(View.GONE)
+
+ if (!TextUtils.isEmpty(info.getSubChannelName())) {
+ displayBothUploaderAndSubChannel(info)
+ } else {
+ displayUploaderAsSubChannel(info)
+ }
+
+ if (info.getViewCount() >= 0) {
+ if (info.getStreamType() == StreamType.AUDIO_LIVE_STREAM) {
+ binding!!.detailViewCountView.setText(
+ Localization.listeningCount(
+ activity,
+ info.getViewCount()
+ )
+ )
+ } else if (info.getStreamType() == StreamType.LIVE_STREAM) {
+ binding!!.detailViewCountView.setText(
+ Localization
+ .localizeWatchingCount(activity, info.getViewCount())
+ )
+ } else {
+ binding!!.detailViewCountView.setText(
+ Localization
+ .localizeViewCount(activity, info.getViewCount())
+ )
+ }
+ binding!!.detailViewCountView.setVisibility(View.VISIBLE)
+ } else {
+ binding!!.detailViewCountView.setVisibility(View.GONE)
+ }
+
+ if (info.getDislikeCount() == -1L && info.getLikeCount() == -1L) {
+ binding!!.detailThumbsDownImgView.setVisibility(View.VISIBLE)
+ binding!!.detailThumbsUpImgView.setVisibility(View.VISIBLE)
+ binding!!.detailThumbsUpCountView.setVisibility(View.GONE)
+ binding!!.detailThumbsDownCountView.setVisibility(View.GONE)
+
+ binding!!.detailThumbsDisabledView.setVisibility(View.VISIBLE)
+ } else {
+ if (info.getDislikeCount() >= 0) {
+ binding!!.detailThumbsDownCountView.setText(
+ Localization
+ .shortCount(activity, info.getDislikeCount())
+ )
+ binding!!.detailThumbsDownCountView.setVisibility(View.VISIBLE)
+ binding!!.detailThumbsDownImgView.setVisibility(View.VISIBLE)
+ } else {
+ binding!!.detailThumbsDownCountView.setVisibility(View.GONE)
+ binding!!.detailThumbsDownImgView.setVisibility(View.GONE)
+ }
+
+ if (info.getLikeCount() >= 0) {
+ binding!!.detailThumbsUpCountView.setText(
+ Localization.shortCount(
+ activity,
+ info.getLikeCount()
+ )
+ )
+ binding!!.detailThumbsUpCountView.setVisibility(View.VISIBLE)
+ binding!!.detailThumbsUpImgView.setVisibility(View.VISIBLE)
+ } else {
+ binding!!.detailThumbsUpCountView.setVisibility(View.GONE)
+ binding!!.detailThumbsUpImgView.setVisibility(View.GONE)
+ }
+ binding!!.detailThumbsDisabledView.setVisibility(View.GONE)
+ }
+
+ if (info.getDuration() > 0) {
+ binding!!.detailDurationView.setText(Localization.getDurationString(info.getDuration()))
+ binding!!.detailDurationView.setBackgroundColor(
+ ContextCompat.getColor(activity, R.color.duration_background_color)
+ )
+ binding!!.detailDurationView.animate(true, 100)
+ } else if (info.getStreamType() == StreamType.LIVE_STREAM) {
+ binding!!.detailDurationView.setText(R.string.duration_live)
+ binding!!.detailDurationView.setBackgroundColor(
+ ContextCompat.getColor(activity, R.color.live_duration_background_color)
+ )
+ binding!!.detailDurationView.animate(true, 100)
+ } else {
+ binding!!.detailDurationView.setVisibility(View.GONE)
+ }
+
+ binding!!.detailTitleRootLayout.setClickable(true)
+ binding!!.detailToggleSecondaryControlsView.setRotation(0f)
+ binding!!.detailToggleSecondaryControlsView.setVisibility(View.VISIBLE)
+ binding!!.detailSecondaryControlPanel.setVisibility(View.GONE)
+
+ checkUpdateProgressInfo(info)
+ loadDetailsThumbnail(
+ binding!!.detailThumbnailImageView,
+ info.getThumbnails()
+ )
+ ExtractorHelper.showMetaInfoInTextView(
+ info.getMetaInfo(), binding!!.detailMetaInfoTextView,
+ binding!!.detailMetaInfoSeparator, disposables
+ )
+
+ if (!this.isPlayerAvailable || player!!.isStopped()) {
+ updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails())
+ }
+
+ if (!info.getErrors().isEmpty()) {
+ // Bandcamp fan pages are not yet supported and thus a ContentNotAvailableException is
+ // thrown. This is not an error and thus should not be shown to the user.
+ for (throwable in info.getErrors()) {
+ if (throwable is ContentNotSupportedException &&
+ "Fan pages are not supported" == throwable.message
+ ) {
+ info.getErrors().remove(throwable)
+ }
+ }
+
+ if (!info.getErrors().isEmpty()) {
+ showSnackBarError(
+ ErrorInfo(
+ info.getErrors(),
+ UserAction.REQUESTED_STREAM, info.getUrl(), info
+ )
+ )
+ }
+ }
+
+ binding!!.detailControlsDownload.setVisibility(
+ if (StreamTypeUtil.isLiveStream(info.getStreamType())) View.GONE else View.VISIBLE
+ )
+ binding!!.detailControlsBackground.setVisibility(
+ if (info.getAudioStreams().isEmpty() && info.getVideoStreams().isEmpty())
+ View.GONE
+ else
+ View.VISIBLE
+ )
+
+ val noVideoStreams =
+ info.getVideoStreams().isEmpty() && info.getVideoOnlyStreams().isEmpty()
+ binding!!.detailControlsPopup.setVisibility(if (noVideoStreams) View.GONE else View.VISIBLE)
+ binding!!.detailThumbnailPlayButton.setImageResource(
+ if (noVideoStreams) R.drawable.ic_headset_shadow else R.drawable.ic_play_arrow_shadow
+ )
+ }
+
+ private fun displayUploaderAsSubChannel(info: StreamInfo) {
+ binding!!.detailSubChannelTextView.setText(info.getUploaderName())
+ binding!!.detailSubChannelTextView.setVisibility(View.VISIBLE)
+ binding!!.detailSubChannelTextView.setSelected(true)
+
+ if (info.getUploaderSubscriberCount() > -1) {
+ binding!!.detailUploaderTextView.setText(
+ Localization.shortSubscriberCount(activity, info.getUploaderSubscriberCount())
+ )
+ binding!!.detailUploaderTextView.setVisibility(View.VISIBLE)
+ } else {
+ binding!!.detailUploaderTextView.setVisibility(View.GONE)
+ }
+
+ loadAvatar(
+ binding!!.detailSubChannelThumbnailView,
+ info.getUploaderAvatars()
+ )
+ binding!!.detailSubChannelThumbnailView.setVisibility(View.VISIBLE)
+ binding!!.detailUploaderThumbnailView.setVisibility(View.GONE)
+ }
+
+ private fun displayBothUploaderAndSubChannel(info: StreamInfo) {
+ binding!!.detailSubChannelTextView.setText(info.getSubChannelName())
+ binding!!.detailSubChannelTextView.setVisibility(View.VISIBLE)
+ binding!!.detailSubChannelTextView.setSelected(true)
+
+ val subText = StringBuilder()
+ if (!TextUtils.isEmpty(info.getUploaderName())) {
+ subText.append(
+ String.format(getString(R.string.video_detail_by), info.getUploaderName())
+ )
+ }
+ if (info.getUploaderSubscriberCount() > -1) {
+ if (subText.length > 0) {
+ subText.append(Localization.DOT_SEPARATOR)
+ }
+ subText.append(
+ Localization.shortSubscriberCount(activity, info.getUploaderSubscriberCount())
+ )
+ }
+
+ if (subText.length > 0) {
+ binding!!.detailUploaderTextView.setText(subText)
+ binding!!.detailUploaderTextView.setVisibility(View.VISIBLE)
+ binding!!.detailUploaderTextView.setSelected(true)
+ } else {
+ binding!!.detailUploaderTextView.setVisibility(View.GONE)
+ }
+
+ loadAvatar(
+ binding!!.detailSubChannelThumbnailView,
+ info.getSubChannelAvatars()
+ )
+ binding!!.detailSubChannelThumbnailView.setVisibility(View.VISIBLE)
+ loadAvatar(
+ binding!!.detailUploaderThumbnailView,
+ info.getUploaderAvatars()
+ )
+ binding!!.detailUploaderThumbnailView.setVisibility(View.VISIBLE)
+ }
+
+ fun openDownloadDialog() {
+ if (currentInfo == null) {
+ return
+ }
+
+ try {
+ val downloadDialog = DownloadDialog(activity, currentInfo!!)
+ downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog")
+ } catch (e: Exception) {
+ showSnackbar(
+ activity,
+ ErrorInfo(
+ e, UserAction.DOWNLOAD_OPEN_DIALOG,
+ "Showing download dialog", currentInfo
+ )
+ )
+ }
+ }
+
+ /*//////////////////////////////////////////////////////////////////////////
+ // Stream Results
+ ////////////////////////////////////////////////////////////////////////// */
+ private fun checkUpdateProgressInfo(info: StreamInfo) {
+ if (positionSubscriber != null) {
+ positionSubscriber!!.dispose()
+ }
+ if (!DependentPreferenceHelper.getResumePlaybackEnabled(activity)) {
+ binding!!.positionView.setVisibility(View.GONE)
+ binding!!.detailPositionView.setVisibility(View.GONE)
+ return
+ }
+ val recordManager = HistoryRecordManager(requireContext())
+ positionSubscriber = recordManager.loadStreamState(info)
+ .subscribeOn(Schedulers.io())
+ .onErrorComplete()
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ io.reactivex.rxjava3.functions.Consumer { state: StreamStateEntity? ->
+ updatePlaybackProgress(
+ state!!.getProgressMillis(), info.getDuration() * 1000
+ )
+ },
+ io.reactivex.rxjava3.functions.Consumer { e: Throwable? -> },
+ Action {
+ binding!!.positionView.setVisibility(View.GONE)
+ binding!!.detailPositionView.setVisibility(View.GONE)
+ }
+ )
+ }
+
+ private fun updatePlaybackProgress(progress: Long, duration: Long) {
+ if (!DependentPreferenceHelper.getResumePlaybackEnabled(activity)) {
+ return
+ }
+ val progressSeconds = TimeUnit.MILLISECONDS.toSeconds(progress).toInt()
+ val durationSeconds = TimeUnit.MILLISECONDS.toSeconds(duration).toInt()
+ // If the old and the new progress values have a big difference then use animation.
+ // Otherwise don't because it affects CPU
+ val progressDifference = abs(
+ (
+ binding!!.positionView.getProgress() -
+ progressSeconds
+ ).toDouble()
+ ).toInt()
+ binding!!.positionView.setMax(durationSeconds)
+ if (progressDifference > 2) {
+ binding!!.positionView.setProgressAnimated(progressSeconds)
+ } else {
+ binding!!.positionView.setProgress(progressSeconds)
+ }
+ val position = Localization.getDurationString(progressSeconds.toLong())
+ if (position !== binding!!.detailPositionView.getText()) {
+ binding!!.detailPositionView.setText(position)
+ }
+ if (binding!!.positionView.getVisibility() != View.VISIBLE) {
+ binding!!.positionView.animate(true, 100)
+ binding!!.detailPositionView.animate(true, 100)
+ }
+ }
+
+ /*//////////////////////////////////////////////////////////////////////////
+ // Player event listener
+ ////////////////////////////////////////////////////////////////////////// */
+ override fun onViewCreated() {
+ tryAddVideoPlayerView()
+ }
+
+ override fun onQueueUpdate(queue: PlayQueue) {
+ playQueue = queue
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ (
+ "onQueueUpdate() called with: serviceId = [" +
+ serviceId + "], url = [" + url + "], name = [" +
+ title + "], playQueue = [" + playQueue + "]"
+ )
+ )
+ }
+
+ // Register broadcast receiver to listen to playQueue changes
+ // and hide the overlayPlayQueueButton when the playQueue is empty / destroyed.
+ if (playQueue != null && playQueue!!.getBroadcastReceiver() != null) {
+ playQueue!!.getBroadcastReceiver()!!.subscribe(
+ io.reactivex.rxjava3.functions.Consumer { event: PlayQueueEvent? -> updateOverlayPlayQueueButtonVisibility() }
+ )
+ }
+
+ // This should be the only place where we push data to stack.
+ // It will allow to have live instance of PlayQueue with actual information about
+ // deleted/added items inside Channel/Playlist queue and makes possible to have
+ // a history of played items
+ val stackPeek: StackItem? = stack.peek()
+ if (stackPeek != null && stackPeek.getPlayQueue() != queue) {
+ val playQueueItem = queue.getItem()
+ if (playQueueItem != null) {
+ stack.push(
+ StackItem(
+ playQueueItem.getServiceId(), playQueueItem.getUrl(),
+ playQueueItem.getTitle(), queue
+ )
+ )
+ return
+ } // else continue below
+ }
+
+ val stackWithQueue = findQueueInStack(queue)
+ if (stackWithQueue != null) {
+ // On every MainPlayer service's destroy() playQueue gets disposed and
+ // no longer able to track progress. That's why we update our cached disposed
+ // queue with the new one that is active and have the same history.
+ // Without that the cached playQueue will have an old recovery position
+ stackWithQueue.setPlayQueue(queue)
+ }
+ }
+
+ override fun onPlaybackUpdate(
+ state: Int,
+ repeatMode: Int,
+ shuffled: Boolean,
+ parameters: PlaybackParameters?
+ ) {
+ setOverlayPlayPauseImage(player != null && player!!.isPlaying())
+
+ if (state == Player.STATE_PLAYING) {
+ if (binding!!.positionView.getAlpha() != 1.0f && player!!.getPlayQueue() != null && player!!.getPlayQueue()!!
+ .getItem() != null && player!!.getPlayQueue()!!.getItem()!!.getUrl() == url
+ ) {
+ binding!!.positionView.animate(true, 100)
+ binding!!.detailPositionView.animate(true, 100)
+ }
+ }
+ }
+
+ override fun onProgressUpdate(
+ currentProgress: Int,
+ duration: Int,
+ bufferPercent: Int
+ ) {
+ // Progress updates every second even if media is paused. It's useless until playing
+ if (!player!!.isPlaying() || playQueue == null) {
+ return
+ }
+
+ if (player!!.getPlayQueue()!!.getItem()!!.getUrl() == url) {
+ updatePlaybackProgress(currentProgress.toLong(), duration.toLong())
+ }
+ }
+
+ override fun onMetadataUpdate(info: StreamInfo, queue: PlayQueue) {
+ val item = findQueueInStack(queue)
+ if (item != null) {
+ // When PlayQueue can have multiple streams (PlaylistPlayQueue or ChannelPlayQueue)
+ // every new played stream gives new title and url.
+ // StackItem contains information about first played stream. Let's update it here
+ item.setTitle(info.getName())
+ item.setUrl(info.getUrl())
+ }
+ // They are not equal when user watches something in popup while browsing in fragment and
+ // then changes screen orientation. In that case the fragment will set itself as
+ // a service listener and will receive initial call to onMetadataUpdate()
+ if (queue != playQueue) {
+ return
+ }
+
+ updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails())
+ if (currentInfo != null && info.getUrl() == currentInfo!!.getUrl()) {
+ return
+ }
+
+ currentInfo = info
+ setInitialData(info.getServiceId(), info.getUrl(), info.getName(), queue)
+ setAutoPlay(false)
+ // Delay execution just because it freezes the main thread, and while playing
+ // next/previous video you see visual glitches
+ // (when non-vertical video goes after vertical video)
+ prepareAndHandleInfoIfNeededAfterDelay(info, true, 200)
+ }
+
+ override fun onPlayerError(error: PlaybackException?, isCatchableException: Boolean) {
+ if (!isCatchableException) {
+ // Properly exit from fullscreen
+ toggleFullscreenIfInFullscreenMode()
+ hideMainPlayerOnLoadingNewStream()
+ }
+ }
+
+ override fun onServiceStopped() {
+ // the binding could be null at this point, if the app is finishing
+ if (binding != null) {
+ setOverlayPlayPauseImage(false)
+ if (currentInfo != null) {
+ updateOverlayData(
+ currentInfo!!.getName(),
+ currentInfo!!.getUploaderName(),
+ currentInfo!!.getThumbnails()
+ )
+ }
+ updateOverlayPlayQueueButtonVisibility()
+ }
+ }
+
+ override fun onFullscreenStateChanged(fullscreen: Boolean) {
+ setupBrightness()
+ if (!this.isPlayerAndPlayerServiceAvailable || player!!.UIs()
+ .getOpt(MainPlayerUi::class.java).isEmpty() ||
+ this.root.map(Function { obj: View? -> obj!!.getParent() }).isEmpty()
+ ) {
+ return
+ }
+
+ if (fullscreen) {
+ hideSystemUiIfNeeded()
+ binding!!.overlayPlayPauseButton.requestFocus()
+ } else {
+ showSystemUi()
+ }
+
+ if (binding!!.relatedItemsLayout != null) {
+ binding!!.relatedItemsLayout!!.setVisibility(if (fullscreen) View.GONE else View.VISIBLE)
+ }
+ scrollToTop()
+
+ tryAddVideoPlayerView()
+ }
+
+ override fun onScreenRotationButtonClicked() {
+ // In tablet user experience will be better if screen will not be rotated
+ // from landscape to portrait every time.
+ // Just turn on fullscreen mode in landscape orientation
+ // or portrait & unlocked global orientation
+ val isLandscape = DeviceUtils.isLandscape(requireContext())
+ if (DeviceUtils.isTablet(activity) &&
+ (!PlayerHelper.globalScreenOrientationLocked(activity) || isLandscape)
+ ) {
+ player!!.UIs().getOpt(MainPlayerUi::class.java)
+ .ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.toggleFullscreen() })
+ return
+ }
+
+ val newOrientation = if (isLandscape)
+ ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+ else
+ ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
+
+ activity.setRequestedOrientation(newOrientation)
+ }
+
+ /*
+ * Will scroll down to description view after long click on moreOptionsButton
+ * */
+ override fun onMoreOptionsLongClicked() {
+ val params =
+ binding!!.appBarLayout.getLayoutParams() as CoordinatorLayout.LayoutParams
+ val behavior = params.getBehavior() as AppBarLayout.Behavior?
+ val valueAnimator = ValueAnimator
+ .ofInt(0, -binding!!.playerPlaceholder.getHeight())
+ valueAnimator.setInterpolator(DecelerateInterpolator())
+ valueAnimator.addUpdateListener(
+ AnimatorUpdateListener { animation: ValueAnimator? ->
+ behavior!!.setTopAndBottomOffset(animation!!.getAnimatedValue() as Int)
+ binding!!.appBarLayout.requestLayout()
+ }
+ )
+ valueAnimator.setInterpolator(DecelerateInterpolator())
+ valueAnimator.setDuration(500)
+ valueAnimator.start()
+ }
+
+ /*//////////////////////////////////////////////////////////////////////////
+ // Player related utils
+ ////////////////////////////////////////////////////////////////////////// */
+ private fun showSystemUi() {
+ if (DEBUG) {
+ Log.d(TAG, "showSystemUi() called")
+ }
+
+ if (activity == null) {
+ return
+ }
+
+ // Prevent jumping of the player on devices with cutout
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ activity.getWindow().getAttributes().layoutInDisplayCutoutMode =
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
+ }
+ activity.getWindow().getDecorView().setSystemUiVisibility(0)
+ activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
+ activity.getWindow().setStatusBarColor(
+ ThemeHelper.resolveColorFromAttr(
+ requireContext(), android.R.attr.colorPrimary
+ )
+ )
+ }
+
+ private fun hideSystemUi() {
+ if (DEBUG) {
+ Log.d(TAG, "hideSystemUi() called")
+ }
+
+ if (activity == null) {
+ return
+ }
+
+ // Prevent jumping of the player on devices with cutout
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ activity.getWindow().getAttributes().layoutInDisplayCutoutMode =
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
+ }
+ var visibility = (
+ View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+ or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
+ )
+
+ // In multiWindow mode status bar is not transparent for devices with cutout
+ // if I include this flag. So without it is better in this case
+ val isInMultiWindow = DeviceUtils.isInMultiWindow(activity)
+ if (!isInMultiWindow) {
+ visibility = visibility or View.SYSTEM_UI_FLAG_FULLSCREEN
+ }
+ activity.getWindow().getDecorView().setSystemUiVisibility(visibility)
+
+ if (isInMultiWindow || this.isFullscreen) {
+ activity.getWindow().setStatusBarColor(Color.TRANSPARENT)
+ activity.getWindow().setNavigationBarColor(Color.TRANSPARENT)
+ }
+ activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
+ }
+
+ // Listener implementation
+ override fun hideSystemUiIfNeeded() {
+ if (this.isFullscreen &&
+ bottomSheetBehavior!!.getState() == BottomSheetBehavior.STATE_EXPANDED
+ ) {
+ hideSystemUi()
+ }
+ }
+
+ private val isFullscreen: Boolean
+ get() = this.isPlayerAvailable && player!!.UIs()
+ .getOpt(VideoPlayerUi::class.java)
+ .map(Function { obj: VideoPlayerUi? -> obj!!.isFullscreen() })
+ .orElse(false)
+
+ private fun playerIsNotStopped(): Boolean {
+ return this.isPlayerAvailable && !player!!.isStopped()
+ }
+
+ private fun restoreDefaultBrightness() {
+ val lp = activity.getWindow().getAttributes()
+ if (lp.screenBrightness == -1f) {
+ return
+ }
+
+ // Restore the old brightness when fragment.onPause() called or
+ // when a player is in portrait
+ lp.screenBrightness = -1f
+ activity.getWindow().setAttributes(lp)
+ }
+
+ private fun setupBrightness() {
+ if (activity == null) {
+ return
+ }
+
+ val lp = activity.getWindow().getAttributes()
+ if (!this.isFullscreen || bottomSheetState != BottomSheetBehavior.STATE_EXPANDED) {
+ // Apply system brightness when the player is not in fullscreen
+ restoreDefaultBrightness()
+ } else {
+ // Do not restore if user has disabled brightness gesture
+ if ((
+ PlayerHelper.getActionForRightGestureSide(activity)
+ != getString(R.string.brightness_control_key)
+ ) && (
+ PlayerHelper.getActionForLeftGestureSide(
+ activity
+ )
+ != getString(R.string.brightness_control_key)
+ )
+ ) {
+ return
+ }
+ // Restore already saved brightness level
+ val brightnessLevel = PlayerHelper.getScreenBrightness(activity)
+ if (brightnessLevel == lp.screenBrightness) {
+ return
+ }
+ lp.screenBrightness = brightnessLevel
+ activity.getWindow().setAttributes(lp)
+ }
+ }
+
+ /**
+ * Make changes to the UI to accommodate for better usability on bigger screens such as TVs
+ * or in Android's desktop mode (DeX etc).
+ */
+ private fun accommodateForTvAndDesktopMode() {
+ if (DeviceUtils.isTv(getContext())) {
+ // remove ripple effects from detail controls
+ val transparent = ContextCompat.getColor(
+ requireContext(),
+ R.color.transparent_background_color
+ )
+ binding!!.detailControlsPlaylistAppend.setBackgroundColor(transparent)
+ binding!!.detailControlsBackground.setBackgroundColor(transparent)
+ binding!!.detailControlsPopup.setBackgroundColor(transparent)
+ binding!!.detailControlsDownload.setBackgroundColor(transparent)
+ binding!!.detailControlsShare.setBackgroundColor(transparent)
+ binding!!.detailControlsOpenInBrowser.setBackgroundColor(transparent)
+ binding!!.detailControlsPlayWithKodi.setBackgroundColor(transparent)
+ }
+ if (DeviceUtils.isDesktopMode(getContext()!!)) {
+ // Remove the "hover" overlay (since it is visible on all mouse events and interferes
+ // with the video content being played)
+ binding!!.detailThumbnailRootLayout.setForeground(null)
+ }
+ }
+
+ private fun checkLandscape() {
+ if ((!player!!.isPlaying() && player!!.getPlayQueue() !== playQueue) ||
+ player!!.getPlayQueue() == null
+ ) {
+ setAutoPlay(true)
+ }
+
+ player!!.UIs().getOpt(MainPlayerUi::class.java)
+ .ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.checkLandscape() })
+ // Let's give a user time to look at video information page if video is not playing
+ if (PlayerHelper.globalScreenOrientationLocked(activity) && !player!!.isPlaying()) {
+ player!!.play()
+ }
+ }
+
+ /*
+ * Means that the player fragment was swiped away via BottomSheetLayout
+ * and is empty but ready for any new actions. See cleanUp()
+ * */
+ private fun wasCleared(): Boolean {
+ return url == null
+ }
+
+ private fun findQueueInStack(queue: PlayQueue?): StackItem? {
+ var item: StackItem? = null
+ val iterator: MutableIterator = stack.descendingIterator()
+ while (iterator.hasNext()) {
+ val next = iterator.next()
+ if (next.getPlayQueue().equals(queue)) {
+ item = next
+ break
+ }
+ }
+ return item
+ }
+
+ private fun replaceQueueIfUserConfirms(onAllow: Runnable) {
+ val activeQueue = if (this.isPlayerAvailable) player!!.getPlayQueue() else null
+
+ // Player will have STATE_IDLE when a user pressed back button
+ if (PlayerHelper.isClearingQueueConfirmationRequired(activity) &&
+ playerIsNotStopped() &&
+ activeQueue != playQueue
+ ) {
+ showClearingQueueConfirmation(onAllow)
+ } else {
+ onAllow.run()
+ }
+ }
+
+ private fun showClearingQueueConfirmation(onAllow: Runnable) {
+ AlertDialog.Builder(activity)
+ .setTitle(R.string.clear_queue_confirmation_description)
+ .setNegativeButton(R.string.cancel, null)
+ .setPositiveButton(
+ R.string.ok,
+ DialogInterface.OnClickListener { dialog: DialogInterface?, which: Int ->
+ onAllow.run()
+ dialog!!.dismiss()
+ }
+ )
+ .show()
+ }
+
+ private fun showExternalVideoPlaybackDialog() {
+ if (currentInfo == null) {
+ return
+ }
+
+ val builder = AlertDialog.Builder(activity)
+ builder.setTitle(R.string.select_quality_external_players)
+ builder.setNeutralButton(
+ R.string.open_in_browser,
+ DialogInterface.OnClickListener { dialog: DialogInterface?, i: Int ->
+ ShareUtils.openUrlInBrowser(
+ requireActivity(),
+ url
+ )
+ }
+ )
+
+ val videoStreamsForExternalPlayers =
+ ListHelper.getSortedStreamVideosList(
+ activity,
+ ListHelper.getUrlAndNonTorrentStreams(currentInfo!!.getVideoStreams()),
+ ListHelper.getUrlAndNonTorrentStreams(currentInfo!!.getVideoOnlyStreams()),
+ false,
+ false
+ )
+
+ if (videoStreamsForExternalPlayers.isEmpty()) {
+ builder.setMessage(R.string.no_video_streams_available_for_external_players)
+ builder.setPositiveButton(R.string.ok, null)
+ } else {
+ val selectedVideoStreamIndexForExternalPlayers =
+ ListHelper.getDefaultResolutionIndex(activity, videoStreamsForExternalPlayers)
+ val resolutions = videoStreamsForExternalPlayers.stream()
+ .map { obj: VideoStream? -> obj!!.getResolution() }
+ .toArray { _Dummy_.__Array__() }
+
+ builder.setSingleChoiceItems(
+ resolutions, selectedVideoStreamIndexForExternalPlayers,
+ null
+ )
+ builder.setNegativeButton(R.string.cancel, null)
+ builder.setPositiveButton(
+ R.string.ok,
+ DialogInterface.OnClickListener { dialog: DialogInterface?, i: Int ->
+ val index = (dialog as AlertDialog).getListView().getCheckedItemPosition()
+ // We don't have to manage the index validity because if there is no stream
+ // available for external players, this code will be not executed and if there is
+ // no stream which matches the default resolution, 0 is returned by
+ // ListHelper.getDefaultResolutionIndex.
+ // The index cannot be outside the bounds of the list as its always between 0 and
+ // the list size - 1, .
+ startOnExternalPlayer(
+ activity, currentInfo!!,
+ videoStreamsForExternalPlayers.get(index)!!
+ )
+ }
+ )
+ }
+ builder.show()
+ }
+
+ private fun showExternalAudioPlaybackDialog() {
+ if (currentInfo == null) {
+ return
+ }
+
+ val audioStreams = ListHelper.getUrlAndNonTorrentStreams(
+ currentInfo!!.getAudioStreams()
+ )
+ val audioTracks =
+ ListHelper.getFilteredAudioStreams(activity, audioStreams)
+
+ if (audioTracks.isEmpty()) {
+ Toast.makeText(
+ activity, R.string.no_audio_streams_available_for_external_players,
+ Toast.LENGTH_SHORT
+ ).show()
+ } else if (audioTracks.size == 1) {
+ startOnExternalPlayer(activity, currentInfo!!, audioTracks.get(0)!!)
+ } else {
+ val selectedAudioStream =
+ ListHelper.getDefaultAudioFormat(activity, audioTracks)
+ val trackNames = audioTracks.stream()
+ .map { audioStream: AudioStream? ->
+ Localization.audioTrackName(
+ activity,
+ audioStream
+ )
+ }
+ .toArray { _Dummy_.__Array__() }
+
+ AlertDialog.Builder(activity)
+ .setTitle(R.string.select_audio_track_external_players)
+ .setNeutralButton(
+ R.string.open_in_browser,
+ DialogInterface.OnClickListener { dialog: DialogInterface?, i: Int ->
+ ShareUtils.openUrlInBrowser(
+ requireActivity(),
+ url
+ )
+ }
+ )
+ .setSingleChoiceItems(trackNames, selectedAudioStream, null)
+ .setNegativeButton(R.string.cancel, null)
+ .setPositiveButton(
+ R.string.ok,
+ DialogInterface.OnClickListener { dialog: DialogInterface?, i: Int ->
+ val index = (dialog as AlertDialog).getListView()
+ .getCheckedItemPosition()
+ startOnExternalPlayer(activity, currentInfo!!, audioTracks.get(index)!!)
+ }
+ )
+ .show()
+ }
+ }
+
+ /*
+ * Remove unneeded information while waiting for a next task
+ * */
+ private fun cleanUp() {
+ // New beginning
+ stack.clear()
+ if (currentWorker != null) {
+ currentWorker!!.dispose()
+ }
+ playerHolder.stopService()
+ setInitialData(0, null, "", null)
+ currentInfo = null
+ updateOverlayData(null, null, mutableListOf())
+ }
+
+ /*//////////////////////////////////////////////////////////////////////////
+ // Bottom mini player
+ ////////////////////////////////////////////////////////////////////////// */
+ /**
+ * That's for Android TV support. Move focus from main fragment to the player or back
+ * based on what is currently selected
+ *
+ * @param toMain if true than the main fragment will be focused or the player otherwise
+ */
+ private fun moveFocusToMainFragment(toMain: Boolean) {
+ setupBrightness()
+ val mainFragment = requireActivity().findViewById(R.id.fragment_holder)
+ // Hamburger button steels a focus even under bottomSheet
+ val toolbar = requireActivity().findViewById(R.id.toolbar)
+ val afterDescendants = ViewGroup.FOCUS_AFTER_DESCENDANTS
+ val blockDescendants = ViewGroup.FOCUS_BLOCK_DESCENDANTS
+ if (toMain) {
+ mainFragment.setDescendantFocusability(afterDescendants)
+ toolbar.setDescendantFocusability(afterDescendants)
+ (requireView() as ViewGroup).setDescendantFocusability(blockDescendants)
+ // Only focus the mainFragment if the mainFragment (e.g. search-results)
+ // or the toolbar (e.g. Textfield for search) don't have focus.
+ // This was done to fix problems with the keyboard input, see also #7490
+ if (!mainFragment.hasFocus() && !toolbar.hasFocus()) {
+ mainFragment.requestFocus()
+ }
+ } else {
+ mainFragment.setDescendantFocusability(blockDescendants)
+ toolbar.setDescendantFocusability(blockDescendants)
+ (requireView() as ViewGroup).setDescendantFocusability(afterDescendants)
+ // Only focus the player if it not already has focus
+ if (!binding!!.getRoot().hasFocus()) {
+ binding!!.detailThumbnailRootLayout.requestFocus()
+ }
+ }
+ }
+
+ /**
+ * When the mini player exists the view underneath it is not touchable.
+ * Bottom padding should be equal to the mini player's height in this case
+ *
+ * @param showMore whether main fragment should be expanded or not
+ */
+ private fun manageSpaceAtTheBottom(showMore: Boolean) {
+ val peekHeight = getResources().getDimensionPixelSize(R.dimen.mini_player_height)
+ val holder = requireActivity().findViewById(R.id.fragment_holder)
+ val newBottomPadding: Int
+ if (showMore) {
+ newBottomPadding = 0
+ } else {
+ newBottomPadding = peekHeight
+ }
+ if (holder.getPaddingBottom() == newBottomPadding) {
+ return
+ }
+ holder.setPadding(
+ holder.getPaddingLeft(),
+ holder.getPaddingTop(),
+ holder.getPaddingRight(),
+ newBottomPadding
+ )
+ }
+
+ private fun setupBottomPlayer() {
+ val params =
+ binding!!.appBarLayout.getLayoutParams() as CoordinatorLayout.LayoutParams
+ val behavior = params.getBehavior() as AppBarLayout.Behavior?
+
+ val bottomSheetLayout = activity.findViewById(R.id.fragment_player_holder)
+ bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout)
+ bottomSheetBehavior!!.setState(lastStableBottomSheetState)
+ updateBottomSheetState(lastStableBottomSheetState)
+
+ val peekHeight = getResources().getDimensionPixelSize(R.dimen.mini_player_height)
+ if (bottomSheetState != BottomSheetBehavior.STATE_HIDDEN) {
+ manageSpaceAtTheBottom(false)
+ bottomSheetBehavior!!.setPeekHeight(peekHeight)
+ if (bottomSheetState == BottomSheetBehavior.STATE_COLLAPSED) {
+ binding!!.overlayLayout.setAlpha(MAX_OVERLAY_ALPHA)
+ } else if (bottomSheetState == BottomSheetBehavior.STATE_EXPANDED) {
+ binding!!.overlayLayout.setAlpha(0f)
+ setOverlayElementsClickable(false)
+ }
+ }
+
+ bottomSheetCallback = object : BottomSheetCallback() {
+ override fun onStateChanged(bottomSheet: View, newState: Int) {
+ updateBottomSheetState(newState)
+
+ when (newState) {
+ BottomSheetBehavior.STATE_HIDDEN -> {
+ moveFocusToMainFragment(true)
+ manageSpaceAtTheBottom(true)
+
+ bottomSheetBehavior!!.setPeekHeight(0)
+ cleanUp()
+ }
+
+ BottomSheetBehavior.STATE_EXPANDED -> {
+ moveFocusToMainFragment(false)
+ manageSpaceAtTheBottom(false)
+
+ bottomSheetBehavior!!.setPeekHeight(peekHeight)
+ // Disable click because overlay buttons located on top of buttons
+ // from the player
+ setOverlayElementsClickable(false)
+ hideSystemUiIfNeeded()
+ // Conditions when the player should be expanded to fullscreen
+ if (DeviceUtils.isLandscape(requireContext()) &&
+ this.isPlayerAvailable &&
+ player!!.isPlaying() &&
+ !this.isFullscreen && !DeviceUtils.isTablet(activity)
+ ) {
+ player!!.UIs().getOpt(MainPlayerUi::class.java)
+ .ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.toggleFullscreen() })
+ }
+ setOverlayLook(binding!!.appBarLayout, behavior, 1f)
+ }
+
+ BottomSheetBehavior.STATE_COLLAPSED -> {
+ moveFocusToMainFragment(true)
+ manageSpaceAtTheBottom(false)
+
+ bottomSheetBehavior!!.setPeekHeight(peekHeight)
+
+ // Re-enable clicks
+ setOverlayElementsClickable(true)
+ if (this.isPlayerAvailable) {
+ player!!.UIs().getOpt(MainPlayerUi::class.java)
+ .ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.closeItemsList() })
+ }
+ setOverlayLook(binding!!.appBarLayout, behavior, 0f)
+ }
+
+ BottomSheetBehavior.STATE_DRAGGING, BottomSheetBehavior.STATE_SETTLING -> {
+ if (this.isFullscreen) {
+ showSystemUi()
+ }
+ if (this.isPlayerAvailable) {
+ player!!.UIs().getOpt(MainPlayerUi::class.java).ifPresent(
+ Consumer { ui: MainPlayerUi? ->
+ if (ui!!.isControlsVisible()) {
+ ui.hideControls(0, 0)
+ }
+ }
+ )
+ }
+ }
+
+ BottomSheetBehavior.STATE_HALF_EXPANDED -> {}
+ }
+ }
+
+ override fun onSlide(bottomSheet: View, slideOffset: Float) {
+ setOverlayLook(binding!!.appBarLayout, behavior, slideOffset)
+ }
+ }
+
+ bottomSheetBehavior!!.addBottomSheetCallback(bottomSheetCallback!!)
+
+ // User opened a new page and the player will hide itself
+ activity.getSupportFragmentManager()
+ .addOnBackStackChangedListener(
+ FragmentManager.OnBackStackChangedListener {
+ if (bottomSheetBehavior!!.getState() == BottomSheetBehavior.STATE_EXPANDED) {
+ bottomSheetBehavior!!.setState(BottomSheetBehavior.STATE_COLLAPSED)
+ }
+ }
+ )
+ }
+
+ private fun updateOverlayPlayQueueButtonVisibility() {
+ val isPlayQueueEmpty =
+ player == null || // no player => no play queue :)
+ player!!.getPlayQueue() == null || player!!.getPlayQueue()!!.isEmpty()
+ if (binding != null) {
+ // binding is null when rotating the device...
+ binding!!.overlayPlayQueueButton.setVisibility(
+ if (isPlayQueueEmpty) View.GONE else View.VISIBLE
+ )
+ }
+ }
+
+ private fun updateOverlayData(
+ overlayTitle: String?,
+ uploader: String?,
+ thumbnails: MutableList
+ ) {
+ binding!!.overlayTitleTextView.setText(if (TextUtils.isEmpty(overlayTitle)) "" else overlayTitle)
+ binding!!.overlayChannelTextView.setText(if (TextUtils.isEmpty(uploader)) "" else uploader)
+ binding!!.overlayThumbnail.setImageDrawable(null)
+ CoilHelper.loadDetailsThumbnail(binding!!.overlayThumbnail, thumbnails)
+ }
+
+ private fun setOverlayPlayPauseImage(playerIsPlaying: Boolean) {
+ val drawable = if (playerIsPlaying)
+ R.drawable.ic_pause
+ else
+ R.drawable.ic_play_arrow
+ binding!!.overlayPlayPauseButton.setImageResource(drawable)
+ }
+
+ private fun setOverlayLook(
+ appBar: AppBarLayout,
+ behavior: AppBarLayout.Behavior?,
+ slideOffset: Float
+ ) {
+ // SlideOffset < 0 when mini player is about to close via swipe.
+ // Stop animation in this case
+ if (behavior == null || slideOffset < 0) {
+ return
+ }
+ binding!!.overlayLayout.setAlpha(
+ min(
+ MAX_OVERLAY_ALPHA.toDouble(),
+ (1 - slideOffset).toDouble()
+ ).toFloat()
+ )
+ // These numbers are not special. They just do a cool transition
+ behavior.setTopAndBottomOffset(
+ (-binding!!.detailThumbnailImageView.getHeight() * 2 * (1 - slideOffset) / 3).toInt()
+ )
+ appBar.requestLayout()
+ }
+
+ private fun setOverlayElementsClickable(enable: Boolean) {
+ binding!!.overlayThumbnail.setClickable(enable)
+ binding!!.overlayThumbnail.setLongClickable(enable)
+ binding!!.overlayMetadataLayout.setClickable(enable)
+ binding!!.overlayMetadataLayout.setLongClickable(enable)
+ binding!!.overlayButtonsLayout.setClickable(enable)
+ binding!!.overlayPlayQueueButton.setClickable(enable)
+ binding!!.overlayPlayPauseButton.setClickable(enable)
+ binding!!.overlayCloseButton.setClickable(enable)
+ }
+
+ val isPlayerAvailable: Boolean
+ // helpers to check the state of player and playerService
+ get() = player != null
+
+ fun noPlayerServiceAvailable(): Boolean {
+ return playerService == null
+ }
+
+ val isPlayerAndPlayerServiceAvailable: Boolean
+ get() = player != null && playerService != null
+
+ val root: Optional
+ get() = Optional.ofNullable(player)
+ .flatMap(
+ Function { player1: Player? ->
+ player1!!.UIs().getOpt(VideoPlayerUi::class.java)
+ }
+ )
+ .map(
+ Function { playerUi: VideoPlayerUi? ->
+ playerUi!!.getBinding().getRoot()
+ }
+ )
+
+ private fun updateBottomSheetState(newState: Int) {
+ bottomSheetState = newState
+ if (newState != BottomSheetBehavior.STATE_DRAGGING &&
+ newState != BottomSheetBehavior.STATE_SETTLING
+ ) {
+ lastStableBottomSheetState = newState
+ }
+ }
+
+ companion object {
+ const val KEY_SWITCHING_PLAYERS: String = "switching_players"
+
+ private const val MAX_OVERLAY_ALPHA = 0.9f
+ private const val MAX_PLAYER_HEIGHT = 0.7f
+
+ @JvmField
+ val ACTION_SHOW_MAIN_PLAYER: String =
+ App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_SHOW_MAIN_PLAYER"
+ @JvmField
+ val ACTION_HIDE_MAIN_PLAYER: String =
+ App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER"
+ @JvmField
+ val ACTION_PLAYER_STARTED: String =
+ App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_PLAYER_STARTED"
+ @JvmField
+ val ACTION_VIDEO_FRAGMENT_RESUMED: String =
+ App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED"
+ @JvmField
+ val ACTION_VIDEO_FRAGMENT_STOPPED: String =
+ App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED"
+
+ private const val COMMENTS_TAB_TAG = "COMMENTS"
+ private const val RELATED_TAB_TAG = "NEXT VIDEO"
+ private const val DESCRIPTION_TAB_TAG = "DESCRIPTION TAB"
+ private const val EMPTY_TAB_TAG = "EMPTY TAB"
+
+ /*//////////////////////////////////////////////////////////////////////// */
+ @JvmStatic
+ fun getInstance(
+ serviceId: Int,
+ url: String?,
+ name: String,
+ queue: PlayQueue?
+ ): VideoDetailFragment {
+ val instance = VideoDetailFragment()
+ instance.setInitialData(serviceId, url, name, queue)
+ return instance
+ }
+
+ @JvmStatic
+ val instanceInCollapsedState: VideoDetailFragment
+ get() {
+ val instance = VideoDetailFragment()
+ instance.updateBottomSheetState(BottomSheetBehavior.STATE_COLLAPSED)
+ return instance
+ }
+
+ /*//////////////////////////////////////////////////////////////////////////
+ // OwnStack
+ ////////////////////////////////////////////////////////////////////////// */
+ /**
+ * Stack that contains the "navigation history".
+ * The peek is the current video.
+ */
+ private var stack = LinkedList()
+ }
+}
From cc3ecd4169acfa49c4e9f8067107fa3c2486d2e9 Mon Sep 17 00:00:00 2001
From: Profpatsch
Date: Tue, 13 May 2025 18:06:49 +0200
Subject: [PATCH 50/87] VideoDetailFragment: convert to kotlin (mechanical,
fixup)
Mostly 1:1, I had to fix a few places where the automatic conversion
did not infer the right kotlin types, and places where it tried to
convert to `double` instead of using `float` like the original.
Everything else is the result of automatic conversion.
---
.../fragments/detail/VideoDetailFragment.kt | 55 +++++++++----------
1 file changed, 27 insertions(+), 28 deletions(-)
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt
index ad9d21481..d94f17e2f 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt
@@ -112,6 +112,7 @@ import org.schabi.newpipe.util.ExtractorHelper
import org.schabi.newpipe.util.InfoCache
import org.schabi.newpipe.util.ListHelper
import org.schabi.newpipe.util.Localization
+import org.schabi.newpipe.util.NO_SERVICE_ID
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.PermissionHelper
import org.schabi.newpipe.util.PlayButtonHelper
@@ -229,7 +230,7 @@ class VideoDetailFragment :
// It will do nothing if the player is not in fullscreen mode
hideSystemUiIfNeeded()
- val playerUi: Optional =
+ val playerUi: Optional =
player!!.UIs().getOpt(MainPlayerUi::class.java)
if (!player!!.videoPlayerSelected() && !playAfterConnect) {
return
@@ -469,8 +470,7 @@ class VideoDetailFragment :
makeOnClickListener(
Consumer { info: StreamInfo? ->
if (getFM() != null && currentInfo != null) {
- val fragment = getParentFragmentManager().findFragmentById
- (R.id.fragment_holder)
+ val fragment = getParentFragmentManager().findFragmentById(R.id.fragment_holder)
// commit previous pending changes to database
if (fragment is LocalPlaylistFragment) {
@@ -715,7 +715,7 @@ class VideoDetailFragment :
View.GONE
)
binding!!.detailControlsCrashThePlayer.setVisibility(
- if (DEBUG && PreferenceManager.getDefaultSharedPreferences(getContext()!!)
+ if (DEBUG && PreferenceManager.getDefaultSharedPreferences(requireContext())
.getBoolean(getString(R.string.show_crash_the_player_key), false)
)
View.VISIBLE
@@ -887,11 +887,11 @@ class VideoDetailFragment :
Handler(Looper.getMainLooper()).postDelayed(
Runnable {
if (activity == null) {
- return@postDelayed
+ return@Runnable
}
// Data can already be drawn, don't spend time twice
if (info.getName() == binding!!.detailVideoTitleView.getText().toString()) {
- return@postDelayed
+ return@Runnable
}
prepareAndHandleInfo(info, scrollToTop)
},
@@ -1296,7 +1296,7 @@ class VideoDetailFragment :
removeVideoPlayerView()
if (this.isAutoplayEnabled) {
playerService!!.stopForImmediateReusing()
- root.ifPresent(Consumer { view: View? -> view!!.setVisibility(View.GONE) })
+ root.ifPresent(Consumer { view: View -> view.setVisibility(View.GONE) })
} else {
playerHolder.stopService()
}
@@ -1373,7 +1373,7 @@ class VideoDetailFragment :
Handler(Looper.getMainLooper()).post(
Runnable {
if (!this.isPlayerAvailable || getView() == null) {
- return@post
+ return@Runnable
}
// setup the surface view height, so that it fits the video correctly
setHeightThumbnail()
@@ -1424,7 +1424,7 @@ class VideoDetailFragment :
activity.getWindow().getDecorView()
).getHeight()
setHeightThumbnail(height, metrics)
- getView()!!.getViewTreeObserver().removeOnPreDrawListener(preDrawListener)
+ requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener)
}
return false
}
@@ -1627,11 +1627,11 @@ class VideoDetailFragment :
binding!!.detailSubChannelThumbnailView.setImageBitmap(null)
}
- override fun handleResult(info: StreamInfo) {
+ override fun handleResult(info: StreamInfo?) {
super.handleResult(info)
currentInfo = info
- setInitialData(info.getServiceId(), info.getOriginalUrl(), info.getName(), playQueue)
+ setInitialData(info!!.getServiceId(), info.getOriginalUrl(), info.getName(), playQueue)
updateTabs(info)
@@ -2277,7 +2277,7 @@ class VideoDetailFragment :
binding!!.detailControlsOpenInBrowser.setBackgroundColor(transparent)
binding!!.detailControlsPlayWithKodi.setBackgroundColor(transparent)
}
- if (DeviceUtils.isDesktopMode(getContext()!!)) {
+ if (DeviceUtils.isDesktopMode(requireContext())) {
// Remove the "hover" overlay (since it is visible on all mouse events and interferes
// with the video content being played)
binding!!.detailThumbnailRootLayout.setForeground(null)
@@ -2309,9 +2309,9 @@ class VideoDetailFragment :
private fun findQueueInStack(queue: PlayQueue?): StackItem? {
var item: StackItem? = null
- val iterator: MutableIterator = stack.descendingIterator()
+ val iterator: MutableIterator = stack.descendingIterator()
while (iterator.hasNext()) {
- val next = iterator.next()
+ val next = iterator.next()!!
if (next.getPlayQueue().equals(queue)) {
item = next
break
@@ -2380,9 +2380,9 @@ class VideoDetailFragment :
} else {
val selectedVideoStreamIndexForExternalPlayers =
ListHelper.getDefaultResolutionIndex(activity, videoStreamsForExternalPlayers)
- val resolutions = videoStreamsForExternalPlayers.stream()
- .map { obj: VideoStream? -> obj!!.getResolution() }
- .toArray { _Dummy_.__Array__() }
+ val resolutions = videoStreamsForExternalPlayers.map {
+ it!!.getResolution() as CharSequence
+ }.toTypedArray()
builder.setSingleChoiceItems(
resolutions, selectedVideoStreamIndexForExternalPlayers,
@@ -2430,14 +2430,13 @@ class VideoDetailFragment :
} else {
val selectedAudioStream =
ListHelper.getDefaultAudioFormat(activity, audioTracks)
- val trackNames = audioTracks.stream()
- .map { audioStream: AudioStream? ->
+ val trackNames = audioTracks
+ .map { audioStream: AudioStream? ->
Localization.audioTrackName(
activity,
audioStream
)
- }
- .toArray { _Dummy_.__Array__() }
+ }.toTypedArray()
AlertDialog.Builder(activity)
.setTitle(R.string.select_audio_track_external_players)
@@ -2476,7 +2475,7 @@ class VideoDetailFragment :
playerHolder.stopService()
setInitialData(0, null, "", null)
currentInfo = null
- updateOverlayData(null, null, mutableListOf())
+ updateOverlayData(null, null, mutableListOf())
}
/*//////////////////////////////////////////////////////////////////////////
@@ -2588,9 +2587,9 @@ class VideoDetailFragment :
hideSystemUiIfNeeded()
// Conditions when the player should be expanded to fullscreen
if (DeviceUtils.isLandscape(requireContext()) &&
- this.isPlayerAvailable &&
+ this@VideoDetailFragment.isPlayerAvailable &&
player!!.isPlaying() &&
- !this.isFullscreen && !DeviceUtils.isTablet(activity)
+ !this@VideoDetailFragment.isFullscreen && !DeviceUtils.isTablet(activity)
) {
player!!.UIs().getOpt(MainPlayerUi::class.java)
.ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.toggleFullscreen() })
@@ -2606,7 +2605,7 @@ class VideoDetailFragment :
// Re-enable clicks
setOverlayElementsClickable(true)
- if (this.isPlayerAvailable) {
+ if (this@VideoDetailFragment.isPlayerAvailable) {
player!!.UIs().getOpt(MainPlayerUi::class.java)
.ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.closeItemsList() })
}
@@ -2614,10 +2613,10 @@ class VideoDetailFragment :
}
BottomSheetBehavior.STATE_DRAGGING, BottomSheetBehavior.STATE_SETTLING -> {
- if (this.isFullscreen) {
+ if (this@VideoDetailFragment.isFullscreen) {
showSystemUi()
}
- if (this.isPlayerAvailable) {
+ if (this@VideoDetailFragment.isPlayerAvailable) {
player!!.UIs().getOpt(MainPlayerUi::class.java).ifPresent(
Consumer { ui: MainPlayerUi? ->
if (ui!!.isControlsVisible()) {
@@ -2665,7 +2664,7 @@ class VideoDetailFragment :
private fun updateOverlayData(
overlayTitle: String?,
uploader: String?,
- thumbnails: MutableList
+ thumbnails: MutableList
) {
binding!!.overlayTitleTextView.setText(if (TextUtils.isEmpty(overlayTitle)) "" else overlayTitle)
binding!!.overlayChannelTextView.setText(if (TextUtils.isEmpty(uploader)) "" else uploader)
From 38ed1da79e079e0cefcbf312570911c60042cdbb Mon Sep 17 00:00:00 2001
From: Profpatsch
Date: Wed, 1 Jan 2025 17:46:48 +0100
Subject: [PATCH 51/87] PlayerHolder: use object class to implement singleton
pattern
---
.../java/org/schabi/newpipe/MainActivity.java | 6 +--
.../org/schabi/newpipe/RouterActivity.java | 2 +-
.../fragments/detail/VideoDetailFragment.kt | 25 +++++-----
.../info_list/dialog/InfoItemDialog.java | 2 +-
.../newpipe/player/helper/PlayerHolder.kt | 48 ++++++++-----------
.../ui/components/items/stream/StreamMenu.kt | 5 +-
.../schabi/newpipe/util/NavigationHelper.java | 8 ++--
7 files changed, 43 insertions(+), 53 deletions(-)
diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java
index 157511c9f..19ae63220 100644
--- a/app/src/main/java/org/schabi/newpipe/MainActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java
@@ -849,7 +849,7 @@ public class MainActivity extends AppCompatActivity {
return;
}
- if (PlayerHolder.Companion.getInstance().isPlayerOpen()) {
+ if (PlayerHolder.INSTANCE.isPlayerOpen()) {
// if the player is already open, no need for a broadcast receiver
openMiniPlayerIfMissing();
} else {
@@ -859,7 +859,7 @@ public class MainActivity extends AppCompatActivity {
public void onReceive(final Context context, final Intent intent) {
if (Objects.equals(intent.getAction(),
VideoDetailFragment.ACTION_PLAYER_STARTED)
- && PlayerHolder.Companion.getInstance().isPlayerOpen()) {
+ && PlayerHolder.INSTANCE.isPlayerOpen()) {
openMiniPlayerIfMissing();
// At this point the player is added 100%, we can unregister. Other actions
// are useless since the fragment will not be removed after that.
@@ -874,7 +874,7 @@ public class MainActivity extends AppCompatActivity {
// If the PlayerHolder is not bound yet, but the service is running, try to bind to it.
// Once the connection is established, the ACTION_PLAYER_STARTED will be sent.
- PlayerHolder.Companion.getInstance().tryBindIfNeeded(this);
+ PlayerHolder.INSTANCE.tryBindIfNeeded(this);
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java
index 27ae603c7..262006243 100644
--- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java
@@ -701,7 +701,7 @@ public class RouterActivity extends AppCompatActivity {
}
// ...the player is not running or in normal Video-mode/type
- final PlayerType playerType = PlayerHolder.Companion.getInstance().getType();
+ final PlayerType playerType = PlayerHolder.INSTANCE.getType();
return playerType == null || playerType == PlayerType.MAIN;
}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt
index d94f17e2f..888843ec6 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt
@@ -100,7 +100,7 @@ import org.schabi.newpipe.player.PlayerType
import org.schabi.newpipe.player.event.OnKeyDownListener
import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener
import org.schabi.newpipe.player.helper.PlayerHelper
-import org.schabi.newpipe.player.helper.PlayerHolder.Companion.getInstance
+import org.schabi.newpipe.player.helper.PlayerHolder
import org.schabi.newpipe.player.playqueue.PlayQueue
import org.schabi.newpipe.player.playqueue.SinglePlayQueue
import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent
@@ -212,7 +212,6 @@ class VideoDetailFragment :
private var settingsContentObserver: ContentObserver? = null
private var playerService: PlayerService? = null
private var player: Player? = null
- private val playerHolder = getInstance()
/*//////////////////////////////////////////////////////////////////////////
// Service management
@@ -367,9 +366,9 @@ class VideoDetailFragment :
// Stop the service when user leaves the app with double back press
// if video player is selected. Otherwise unbind
if (activity.isFinishing() && this.isPlayerAvailable && player!!.videoPlayerSelected()) {
- playerHolder.stopService()
+ PlayerHolder.stopService()
} else {
- playerHolder.setListener(null)
+ PlayerHolder.setListener(null)
}
PreferenceManager.getDefaultSharedPreferences(activity)
@@ -768,10 +767,10 @@ class VideoDetailFragment :
)
setupBottomPlayer()
- if (!playerHolder.isBound) {
+ if (!PlayerHolder.isBound) {
setHeightThumbnail()
} else {
- playerHolder.startService(false, this)
+ PlayerHolder.startService(false, this)
}
}
@@ -1175,7 +1174,7 @@ class VideoDetailFragment :
// See UI changes while remote playQueue changes
if (!this.isPlayerAvailable) {
- playerHolder.startService(false, this)
+ PlayerHolder.startService(false, this)
} else {
// FIXME Workaround #7427
player!!.setRecovery()
@@ -1245,7 +1244,7 @@ class VideoDetailFragment :
private fun openNormalBackgroundPlayer(append: Boolean) {
// See UI changes while remote playQueue changes
if (!this.isPlayerAvailable) {
- playerHolder.startService(false, this)
+ PlayerHolder.startService(false, this)
}
val queue = setupPlayQueueForIntent(append)
@@ -1263,7 +1262,7 @@ class VideoDetailFragment :
private fun openMainPlayer() {
if (noPlayerServiceAvailable()) {
- playerHolder.startService(autoPlayEnabled, this)
+ PlayerHolder.startService(autoPlayEnabled, this)
return
}
if (currentInfo == null) {
@@ -1298,7 +1297,7 @@ class VideoDetailFragment :
playerService!!.stopForImmediateReusing()
root.ifPresent(Consumer { view: View -> view.setVisibility(View.GONE) })
} else {
- playerHolder.stopService()
+ PlayerHolder.stopService()
}
}
@@ -1551,8 +1550,8 @@ class VideoDetailFragment :
bottomSheetBehavior!!.setState(BottomSheetBehavior.STATE_COLLAPSED)
}
// Rebound to the service if it was closed via notification or mini player
- if (!playerHolder.isBound) {
- playerHolder.startService(
+ if (!PlayerHolder.isBound) {
+ PlayerHolder.startService(
false, this@VideoDetailFragment
)
}
@@ -2472,7 +2471,7 @@ class VideoDetailFragment :
if (currentWorker != null) {
currentWorker!!.dispose()
}
- playerHolder.stopService()
+ PlayerHolder.stopService()
setInitialData(0, null, "", null)
currentInfo = null
updateOverlayData(null, null, mutableListOf())
diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java
index 55d49b145..cbaae2834 100644
--- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java
+++ b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java
@@ -252,7 +252,7 @@ public final class InfoItemDialog {
* @return the current {@link Builder} instance
*/
public Builder addEnqueueEntriesIfNeeded() {
- final PlayerHolder holder = PlayerHolder.Companion.getInstance();
+ final PlayerHolder holder = PlayerHolder.INSTANCE;
if (holder.isPlayQueueReady()) {
addEntry(StreamDialogDefaultEntry.ENQUEUE);
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt
index b3196aeb5..06b4f8bba 100644
--- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt
@@ -22,12 +22,19 @@ import org.schabi.newpipe.player.playqueue.PlayQueue
import org.schabi.newpipe.util.NavigationHelper
import java.util.function.Consumer
-class PlayerHolder private constructor() {
+private val DEBUG = MainActivity.DEBUG
+private val TAG: String = PlayerHolder::class.java.getSimpleName()
+
+/**
+ * Singleton that manages a `PlayerService`
+ * and can be used to control the player instance through the service.
+ */
+object PlayerHolder {
private var listener: PlayerServiceExtendedEventListener? = null
- private val serviceConnection = PlayerServiceConnection()
var isBound: Boolean = false
private set
+
private var playerService: PlayerService? = null
private val player: Player?
@@ -110,7 +117,7 @@ class PlayerHolder private constructor() {
val intent = Intent(context, PlayerService::class.java)
intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true)
ContextCompat.startForegroundService(context, intent)
- serviceConnection.doPlayAfterConnect(playAfterConnect)
+ PlayerServiceConnection.doPlayAfterConnect(playAfterConnect)
bind(context)
}
@@ -126,7 +133,7 @@ class PlayerHolder private constructor() {
context.stopService(Intent(context, PlayerService::class.java))
}
- internal inner class PlayerServiceConnection : ServiceConnection {
+ internal object PlayerServiceConnection : ServiceConnection {
internal var playAfterConnect = false
/**
@@ -185,7 +192,7 @@ class PlayerHolder private constructor() {
// BIND_AUTO_CREATE starts the service if it's not already running
this.isBound = bind(context, Context.BIND_AUTO_CREATE)
if (!this.isBound) {
- context.unbindService(serviceConnection)
+ context.unbindService(PlayerServiceConnection)
}
}
@@ -201,7 +208,7 @@ class PlayerHolder private constructor() {
private fun bind(context: Context, flags: Int): Boolean {
val serviceIntent = Intent(context, PlayerService::class.java)
serviceIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION)
- return context.bindService(serviceIntent, serviceConnection, flags)
+ return context.bindService(serviceIntent, PlayerServiceConnection, flags)
}
private fun unbind(context: Context) {
@@ -210,7 +217,7 @@ class PlayerHolder private constructor() {
}
if (this.isBound) {
- context.unbindService(serviceConnection)
+ context.unbindService(PlayerServiceConnection)
this.isBound = false
stopPlayerListener()
playerService = null
@@ -223,18 +230,18 @@ class PlayerHolder private constructor() {
// setting the player listener will take care of calling relevant callbacks if the
// player in the service is (not) already active, also see playerStateListener below
playerService?.setPlayerListener(playerStateListener)
- this.player?.setFragmentListener(internalListener)
+ this.player?.setFragmentListener(HolderPlayerServiceEventListener)
}
private fun stopPlayerListener() {
playerService?.setPlayerListener(null)
- this.player?.removeFragmentListener(internalListener)
+ this.player?.removeFragmentListener(HolderPlayerServiceEventListener)
}
/**
* This listener will be held by the players created by [PlayerService].
*/
- private val internalListener: PlayerServiceEventListener = object : PlayerServiceEventListener {
+ private object HolderPlayerServiceEventListener : PlayerServiceEventListener {
override fun onViewCreated() {
listener?.onViewCreated()
}
@@ -307,26 +314,11 @@ class PlayerHolder private constructor() {
// before setting its player to null
l.onPlayerDisconnected()
} else {
- l.onPlayerConnected(player, serviceConnection.playAfterConnect)
+ l.onPlayerConnected(player, PlayerServiceConnection.playAfterConnect)
// reset the value of playAfterConnect: if it was true before, it is now "consumed"
- serviceConnection.playAfterConnect = false;
- player.setFragmentListener(internalListener)
+ PlayerServiceConnection.playAfterConnect = false
+ player.setFragmentListener(HolderPlayerServiceEventListener)
}
}
}
-
- companion object {
- private var instance: PlayerHolder? = null
-
- @Synchronized
- fun getInstance(): PlayerHolder {
- if (instance == null) {
- instance = PlayerHolder()
- }
- return instance!!
- }
-
- private val DEBUG = MainActivity.DEBUG
- private val TAG: String = PlayerHolder::class.java.getSimpleName()
- }
}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt
index 26d385518..7619515e7 100644
--- a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt
@@ -28,10 +28,9 @@ fun StreamMenu(
) {
val context = LocalContext.current
val streamViewModel = viewModel()
- val playerHolder = PlayerHolder.Companion.getInstance()
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
- if (playerHolder.isPlayQueueReady) {
+ if (PlayerHolder.isPlayQueueReady) {
DropdownMenuItem(
text = { Text(text = stringResource(R.string.enqueue_stream)) },
onClick = {
@@ -42,7 +41,7 @@ fun StreamMenu(
}
)
- if (playerHolder.queuePosition < playerHolder.queueSize - 1) {
+ if (PlayerHolder.queuePosition < PlayerHolder.queueSize - 1) {
DropdownMenuItem(
text = { Text(text = stringResource(R.string.enqueue_next_stream)) },
onClick = {
diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
index 9d8d3c3b2..c71836609 100644
--- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
@@ -200,7 +200,7 @@ public final class NavigationHelper {
}
public static void enqueueOnPlayer(final Context context, final PlayQueue queue) {
- PlayerType playerType = PlayerHolder.Companion.getInstance().getType();
+ PlayerType playerType = PlayerHolder.INSTANCE.getType();
if (playerType == null) {
Log.e(TAG, "Enqueueing but no player is open; defaulting to background player");
playerType = PlayerType.AUDIO;
@@ -211,7 +211,7 @@ public final class NavigationHelper {
/* ENQUEUE NEXT */
public static void enqueueNextOnPlayer(final Context context, final PlayQueue queue) {
- PlayerType playerType = PlayerHolder.Companion.getInstance().getType();
+ PlayerType playerType = PlayerHolder.INSTANCE.getType();
if (playerType == null) {
Log.e(TAG, "Enqueueing next but no player is open; defaulting to background player");
playerType = PlayerType.AUDIO;
@@ -421,13 +421,13 @@ public final class NavigationHelper {
final boolean switchingPlayers) {
final boolean autoPlay;
- @Nullable final PlayerType playerType = PlayerHolder.Companion.getInstance().getType();
+ @Nullable final PlayerType playerType = PlayerHolder.INSTANCE.getType();
if (playerType == null) {
// no player open
autoPlay = PlayerHelper.isAutoplayAllowedByUser(context);
} else if (switchingPlayers) {
// switching player to main player
- autoPlay = PlayerHolder.Companion.getInstance().isPlaying(); // keep play/pause state
+ autoPlay = PlayerHolder.INSTANCE.isPlaying(); // keep play/pause state
} else if (playerType == PlayerType.MAIN) {
// opening new stream while already playing in main player
autoPlay = PlayerHelper.isAutoplayAllowedByUser(context);
From 91aed1e240ac8beee8bb2b7ce8046152cd52f8e5 Mon Sep 17 00:00:00 2001
From: Profpatsch
Date: Wed, 1 Jan 2025 20:46:13 +0100
Subject: [PATCH 52/87] VideoDetailFragment: replace every getOpt() with get()
---
.../fragments/detail/VideoDetailFragment.kt | 130 +++++++-----------
1 file changed, 50 insertions(+), 80 deletions(-)
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt
index 888843ec6..6cfb96438 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt
@@ -31,7 +31,6 @@ import android.view.View
import android.view.View.OnLongClickListener
import android.view.View.OnTouchListener
import android.view.ViewGroup
-import android.view.ViewParent
import android.view.ViewTreeObserver
import android.view.WindowManager
import android.view.animation.DecelerateInterpolator
@@ -126,10 +125,8 @@ import org.schabi.newpipe.util.image.CoilHelper.loadDetailsThumbnail
import java.util.LinkedList
import java.util.List
import java.util.Objects
-import java.util.Optional
import java.util.concurrent.TimeUnit
import java.util.function.Consumer
-import java.util.function.Function
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
@@ -229,8 +226,8 @@ class VideoDetailFragment :
// It will do nothing if the player is not in fullscreen mode
hideSystemUiIfNeeded()
- val playerUi: Optional =
- player!!.UIs().getOpt(MainPlayerUi::class.java)
+ val playerUi: MainPlayerUi? =
+ player!!.UIs().get(MainPlayerUi::class.java)
if (!player!!.videoPlayerSelected() && !playAfterConnect) {
return
}
@@ -239,19 +236,22 @@ class VideoDetailFragment :
// If the video is playing but orientation changed
// let's make the video in fullscreen again
checkLandscape()
- } else if (playerUi.map(Function { ui: MainPlayerUi? -> ui!!.isFullscreen() && !ui.isVerticalVideo() })
- .orElse(false) && // Tablet UI has orientation-independent fullscreen
+ } else if (playerUi != null &&
+ playerUi.isFullscreen() &&
+ !playerUi.isVerticalVideo() &&
+ // Tablet UI has orientation-independent fullscreen
!DeviceUtils.isTablet(activity)
) {
// Device is in portrait orientation after rotation but UI is in fullscreen.
// Return back to non-fullscreen state
- playerUi.ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.toggleFullscreen() })
+ playerUi.toggleFullscreen()
}
if (playAfterConnect ||
(
- currentInfo != null && this.isAutoplayEnabled &&
- playerUi.isEmpty()
+ currentInfo != null &&
+ this.isAutoplayEnabled &&
+ playerUi == null
)
) {
autoPlayEnabled = true // forcefully start playing
@@ -572,8 +572,7 @@ class VideoDetailFragment :
View.OnClickListener { v: View? ->
if (playerIsNotStopped()) {
player!!.playPause()
- player!!.UIs().getOpt(VideoPlayerUi::class.java)
- .ifPresent(Consumer { ui: VideoPlayerUi? -> ui!!.hideControls(0, 0) })
+ player!!.UIs().get(VideoPlayerUi::class.java)?.hideControls(0, 0)
showSystemUi()
} else {
autoPlayEnabled = true // forcefully start playing
@@ -776,9 +775,7 @@ class VideoDetailFragment :
override fun onKeyDown(keyCode: Int): Boolean {
return this.isPlayerAvailable &&
- player!!.UIs().getOpt(VideoPlayerUi::class.java)
- .map(Function { playerUi: VideoPlayerUi? -> playerUi!!.onKeyDown(keyCode) })
- .orElse(false)
+ player!!.UIs().get(VideoPlayerUi::class.java)?.onKeyDown(keyCode) == true
}
override fun onBackPressed(): Boolean {
@@ -1137,14 +1134,11 @@ class VideoDetailFragment :
// If a user watched video inside fullscreen mode and than chose another player
// return to non-fullscreen mode
if (this.isPlayerAvailable) {
- player!!.UIs().getOpt(MainPlayerUi::class.java)
- .ifPresent(
- Consumer { playerUi: MainPlayerUi? ->
- if (playerUi!!.isFullscreen()) {
- playerUi.toggleFullscreen()
- }
- }
- )
+ player!!.UIs().get(MainPlayerUi::class.java)?.let {
+ if (it.isFullscreen) {
+ it.toggleFullscreen()
+ }
+ }
}
}
@@ -1288,14 +1282,14 @@ class VideoDetailFragment :
*/
private fun hideMainPlayerOnLoadingNewStream() {
val root = this.root
- if (noPlayerServiceAvailable() || root.isEmpty() || !player!!.videoPlayerSelected()) {
+ if (noPlayerServiceAvailable() || root == null || !player!!.videoPlayerSelected()) {
return
}
removeVideoPlayerView()
if (this.isAutoplayEnabled) {
playerService!!.stopForImmediateReusing()
- root.ifPresent(Consumer { view: View -> view.setVisibility(View.GONE) })
+ root.setVisibility(View.GONE)
} else {
PlayerHolder.stopService()
}
@@ -1376,18 +1370,16 @@ class VideoDetailFragment :
}
// setup the surface view height, so that it fits the video correctly
setHeightThumbnail()
- player!!.UIs().getOpt(MainPlayerUi::class.java)
- .ifPresent(
- Consumer { playerUi: MainPlayerUi? ->
- // sometimes binding would be null here, even though getView() != null above u.u
- if (binding != null) {
- // prevent from re-adding a view multiple times
- playerUi!!.removeViewFromParent()
- binding!!.playerPlaceholder.addView(playerUi.getBinding().getRoot())
- playerUi.setupVideoSurfaceIfNeeded()
- }
- }
- )
+ player!!.UIs().get(MainPlayerUi::class.java)?.let { playerUi ->
+ val b = binding
+ // sometimes binding would be null here, even though getView() != null above u.u
+ if (b != null) {
+ // prevent from re-adding a view multiple times
+ playerUi.removeViewFromParent()
+ b.playerPlaceholder.addView(playerUi.getBinding().getRoot())
+ playerUi.setupVideoSurfaceIfNeeded()
+ }
+ }
}
)
}
@@ -1396,8 +1388,7 @@ class VideoDetailFragment :
makeDefaultHeightForVideoPlaceholder()
if (player != null) {
- player!!.UIs().getOpt(VideoPlayerUi::class.java)
- .ifPresent(Consumer { obj: VideoPlayerUi? -> obj!!.removeViewFromParent() })
+ player!!.UIs().get(VideoPlayerUi::class.java)?.removeViewFromParent()
}
}
@@ -1474,15 +1465,12 @@ class VideoDetailFragment :
binding!!.detailThumbnailImageView.setMinimumHeight(newHeight)
if (this.isPlayerAvailable) {
val maxHeight = (metrics.heightPixels * MAX_PLAYER_HEIGHT).toInt()
- player!!.UIs().getOpt(VideoPlayerUi::class.java)
- .ifPresent(
- Consumer { ui: VideoPlayerUi? ->
- ui!!.getBinding().surfaceView.setHeights(
- newHeight,
- if (ui.isFullscreen()) newHeight else maxHeight
- )
- }
+ player!!.UIs().get(VideoPlayerUi::class.java)?.let {
+ it.binding.surfaceView.setHeights(
+ newHeight,
+ if (it.isFullscreen) newHeight else maxHeight
)
+ }
}
}
@@ -2065,9 +2053,9 @@ class VideoDetailFragment :
override fun onFullscreenStateChanged(fullscreen: Boolean) {
setupBrightness()
- if (!this.isPlayerAndPlayerServiceAvailable || player!!.UIs()
- .getOpt(MainPlayerUi::class.java).isEmpty() ||
- this.root.map(Function { obj: View? -> obj!!.getParent() }).isEmpty()
+ if (!this.isPlayerAndPlayerServiceAvailable ||
+ player?.UIs()?.get(MainPlayerUi::class.java) == null ||
+ this.root?.parent == null
) {
return
}
@@ -2096,8 +2084,7 @@ class VideoDetailFragment :
if (DeviceUtils.isTablet(activity) &&
(!PlayerHelper.globalScreenOrientationLocked(activity) || isLandscape)
) {
- player!!.UIs().getOpt(MainPlayerUi::class.java)
- .ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.toggleFullscreen() })
+ player!!.UIs().get(MainPlayerUi::class.java)?.toggleFullscreen()
return
}
@@ -2203,10 +2190,8 @@ class VideoDetailFragment :
}
private val isFullscreen: Boolean
- get() = this.isPlayerAvailable && player!!.UIs()
- .getOpt(VideoPlayerUi::class.java)
- .map(Function { obj: VideoPlayerUi? -> obj!!.isFullscreen() })
- .orElse(false)
+ get() = this.isPlayerAvailable && player?.UIs()
+ ?.get(VideoPlayerUi::class.java)?.isFullscreen() == true
private fun playerIsNotStopped(): Boolean {
return this.isPlayerAvailable && !player!!.isStopped()
@@ -2290,8 +2275,7 @@ class VideoDetailFragment :
setAutoPlay(true)
}
- player!!.UIs().getOpt(MainPlayerUi::class.java)
- .ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.checkLandscape() })
+ player!!.UIs().get(MainPlayerUi::class.java)?.checkLandscape()
// Let's give a user time to look at video information page if video is not playing
if (PlayerHelper.globalScreenOrientationLocked(activity) && !player!!.isPlaying()) {
player!!.play()
@@ -2590,8 +2574,7 @@ class VideoDetailFragment :
player!!.isPlaying() &&
!this@VideoDetailFragment.isFullscreen && !DeviceUtils.isTablet(activity)
) {
- player!!.UIs().getOpt(MainPlayerUi::class.java)
- .ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.toggleFullscreen() })
+ player!!.UIs().get(MainPlayerUi::class.java)?.toggleFullscreen()
}
setOverlayLook(binding!!.appBarLayout, behavior, 1f)
}
@@ -2605,8 +2588,7 @@ class VideoDetailFragment :
// Re-enable clicks
setOverlayElementsClickable(true)
if (this@VideoDetailFragment.isPlayerAvailable) {
- player!!.UIs().getOpt(MainPlayerUi::class.java)
- .ifPresent(Consumer { obj: MainPlayerUi? -> obj!!.closeItemsList() })
+ player!!.UIs().get(MainPlayerUi::class.java)?.closeItemsList()
}
setOverlayLook(binding!!.appBarLayout, behavior, 0f)
}
@@ -2616,13 +2598,11 @@ class VideoDetailFragment :
showSystemUi()
}
if (this@VideoDetailFragment.isPlayerAvailable) {
- player!!.UIs().getOpt(MainPlayerUi::class.java).ifPresent(
- Consumer { ui: MainPlayerUi? ->
- if (ui!!.isControlsVisible()) {
- ui.hideControls(0, 0)
- }
+ player!!.UIs().get(MainPlayerUi::class.java)?.let {
+ if (it.isControlsVisible) {
+ it.hideControls(0, 0)
}
- )
+ }
}
}
@@ -2724,18 +2704,8 @@ class VideoDetailFragment :
val isPlayerAndPlayerServiceAvailable: Boolean
get() = player != null && playerService != null
- val root: Optional
- get() = Optional.ofNullable(player)
- .flatMap(
- Function { player1: Player? ->
- player1!!.UIs().getOpt(VideoPlayerUi::class.java)
- }
- )
- .map(
- Function { playerUi: VideoPlayerUi? ->
- playerUi!!.getBinding().getRoot()
- }
- )
+ val root: View?
+ get() = player?.UIs()?.get(VideoPlayerUi::class.java)?.binding?.root
private fun updateBottomSheetState(newState: Int) {
bottomSheetState = newState
From ed0051a3f62a488ab0fa2babb88357168367f67b Mon Sep 17 00:00:00 2001
From: Profpatsch
Date: Tue, 13 May 2025 18:53:02 +0200
Subject: [PATCH 53/87] Player: small class comment
---
app/src/main/java/org/schabi/newpipe/player/Player.java | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java
index 094032a06..49f02efeb 100644
--- a/app/src/main/java/org/schabi/newpipe/player/Player.java
+++ b/app/src/main/java/org/schabi/newpipe/player/Player.java
@@ -133,6 +133,10 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.disposables.SerialDisposable;
+/**
+ * The ExoPlayer wrapper & Player business logic.
+ * Only instantiated once, from {@link PlayerService}.
+ */
public final class Player implements PlaybackListener, Listener {
public static final boolean DEBUG = MainActivity.DEBUG;
public static final String TAG = Player.class.getSimpleName();
From 73305414994f32e39dd20088f677a71cf61b9560 Mon Sep 17 00:00:00 2001
From: Profpatsch
Date: Tue, 13 May 2025 19:08:23 +0200
Subject: [PATCH 54/87] PlayerUIList: remove remaining uses of getOpt
mediaSession is now `@NonNull`, so the getter is as well.
---
.../org/schabi/newpipe/player/Player.java | 21 ++++++++++---------
.../mediasession/MediaSessionPlayerUi.java | 11 +++++++---
.../player/notification/NotificationUtil.java | 8 +++----
.../schabi/newpipe/player/ui/PlayerUiList.kt | 12 -----------
4 files changed, 23 insertions(+), 29 deletions(-)
diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java
index 49f02efeb..57cdd081e 100644
--- a/app/src/main/java/org/schabi/newpipe/player/Player.java
+++ b/app/src/main/java/org/schabi/newpipe/player/Player.java
@@ -477,22 +477,23 @@ public final class Player implements PlaybackListener, Listener {
}
private void initUIsForCurrentPlayerType() {
- if ((UIs.getOpt(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN)
- || (UIs.getOpt(PopupPlayerUi.class).isPresent()
+ if ((UIs.get(MainPlayerUi.class) != null && playerType == PlayerType.MAIN)
+ || (UIs.get(PopupPlayerUi.class) != null
&& playerType == PlayerType.POPUP)) {
// correct UI already in place
return;
}
// try to reuse binding if possible
- final PlayerBinding binding = UIs.getOpt(VideoPlayerUi.class).map(VideoPlayerUi::getBinding)
- .orElseGet(() -> {
- if (playerType == PlayerType.AUDIO) {
- return null;
- } else {
- return PlayerBinding.inflate(LayoutInflater.from(context));
- }
- });
+ @Nullable final VideoPlayerUi ui = UIs.get(VideoPlayerUi.class);
+ final PlayerBinding binding;
+ if (ui != null) {
+ binding = ui.getBinding();
+ } else if (playerType == PlayerType.AUDIO) {
+ binding = null;
+ } else {
+ binding = PlayerBinding.inflate(LayoutInflater.from(context));
+ }
switch (playerType) {
case MAIN:
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java
index 085da5eb7..850dd02e3 100644
--- a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java
+++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java
@@ -124,8 +124,10 @@ public class MediaSessionPlayerUi extends PlayerUi
MediaButtonReceiver.handleIntent(mediaSession, intent);
}
- public Optional getSessionToken() {
- return Optional.ofNullable(mediaSession).map(MediaSessionCompat::getSessionToken);
+
+ @NonNull
+ public MediaSessionCompat.Token getSessionToken() {
+ return mediaSession.getSessionToken();
}
@@ -138,7 +140,10 @@ public class MediaSessionPlayerUi extends PlayerUi
public void play() {
player.play();
// hide the player controls even if the play command came from the media session
- player.UIs().getOpt(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0));
+ final VideoPlayerUi ui = player.UIs().get(VideoPlayerUi.class);
+ if (ui != null) {
+ ui.hideControls(0, 0);
+ }
}
@Override
diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java
index 5658693f2..cc3889973 100644
--- a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java
+++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java
@@ -101,10 +101,10 @@ public final class NotificationUtil {
final int[] compactSlots = initializeNotificationSlots();
mediaStyle.setShowActionsInCompactView(compactSlots);
}
- player.UIs()
- .getOpt(MediaSessionPlayerUi.class)
- .flatMap(MediaSessionPlayerUi::getSessionToken)
- .ifPresent(mediaStyle::setMediaSession);
+ @Nullable final MediaSessionPlayerUi ui = player.UIs().get(MediaSessionPlayerUi.class);
+ if (ui != null) {
+ mediaStyle.setMediaSession(ui.getSessionToken());
+ }
// setup notification builder
builder.setStyle(mediaStyle)
diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt
index ec0c85c93..ef9c6f3c2 100644
--- a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt
+++ b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt
@@ -1,7 +1,6 @@
package org.schabi.newpipe.player.ui
import org.schabi.newpipe.util.GuardedByMutex
-import java.util.Optional
/**
* Creates a [PlayerUiList] starting with the provided player uis. The provided player uis
@@ -99,17 +98,6 @@ class PlayerUiList(vararg initialPlayerUis: PlayerUi) {
return@runWithLockSync null
}
- /**
- * @param playerUiType the class of the player UI to return;
- * the [Class.isInstance] method will be used, so even subclasses could be returned
- * @param T the class type parameter
- * @return the first player UI of the required type found in the list, or an empty
- * [Optional] otherwise
- */
- @Deprecated("use get", ReplaceWith("get(playerUiType)"))
- fun getOpt(playerUiType: Class): Optional =
- Optional.ofNullable(get(playerUiType))
-
/**
* Calls the provided consumer on all player UIs in the list, in order of addition.
* @param consumer the consumer to call with player UIs
From 3f62ec7e5344c161429fadd4e0db99aaf8416d68 Mon Sep 17 00:00:00 2001
From: Stypox
Date: Mon, 9 Jun 2025 15:22:17 +0200
Subject: [PATCH 55/87] Improve Kotlin converted from java in various places
---
.../fragments/detail/VideoDetailFragment.kt | 1793 +++++++----------
.../schabi/newpipe/player/PlayerService.kt | 102 +-
.../newpipe/player/helper/PlayerHolder.kt | 30 +-
.../player/mediabrowser/MediaBrowserImpl.kt | 17 +-
.../schabi/newpipe/player/ui/PlayerUiList.kt | 14 +-
5 files changed, 750 insertions(+), 1206 deletions(-)
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt
index 6cfb96438..db87f37dc 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt
@@ -1,12 +1,10 @@
package org.schabi.newpipe.fragments.detail
import android.animation.ValueAnimator
-import android.animation.ValueAnimator.AnimatorUpdateListener
import android.annotation.SuppressLint
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
-import android.content.DialogInterface
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
@@ -15,7 +13,6 @@ import android.content.pm.ActivityInfo
import android.database.ContentObserver
import android.graphics.Color
import android.graphics.Rect
-import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
@@ -31,7 +28,7 @@ import android.view.View
import android.view.View.OnLongClickListener
import android.view.View.OnTouchListener
import android.view.ViewGroup
-import android.view.ViewTreeObserver
+import android.view.ViewTreeObserver.OnPreDrawListener
import android.view.WindowManager
import android.view.animation.DecelerateInterpolator
import android.widget.FrameLayout
@@ -44,7 +41,10 @@ import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
-import androidx.fragment.app.FragmentManager
+import androidx.core.content.edit
+import androidx.core.net.toUri
+import androidx.core.os.postDelayed
+import androidx.core.view.isVisible
import androidx.preference.PreferenceManager
import coil3.util.CoilUtils.dispose
import com.evernote.android.state.State
@@ -57,12 +57,10 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCa
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
-import io.reactivex.rxjava3.functions.Action
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.App
import org.schabi.newpipe.R
import org.schabi.newpipe.database.stream.model.StreamEntity
-import org.schabi.newpipe.database.stream.model.StreamStateEntity
import org.schabi.newpipe.databinding.FragmentVideoDetailBinding
import org.schabi.newpipe.download.DownloadDialog
import org.schabi.newpipe.error.ErrorInfo
@@ -75,12 +73,10 @@ import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
import org.schabi.newpipe.extractor.exceptions.ExtractionException
-import org.schabi.newpipe.extractor.stream.AudioStream
import org.schabi.newpipe.extractor.stream.Stream
import org.schabi.newpipe.extractor.stream.StreamExtractor
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamType
-import org.schabi.newpipe.extractor.stream.VideoStream
import org.schabi.newpipe.fragments.BackPressable
import org.schabi.newpipe.fragments.BaseStateFragment
import org.schabi.newpipe.fragments.EmptyFragment
@@ -102,7 +98,6 @@ import org.schabi.newpipe.player.helper.PlayerHelper
import org.schabi.newpipe.player.helper.PlayerHolder
import org.schabi.newpipe.player.playqueue.PlayQueue
import org.schabi.newpipe.player.playqueue.SinglePlayQueue
-import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent
import org.schabi.newpipe.player.ui.MainPlayerUi
import org.schabi.newpipe.player.ui.VideoPlayerUi
import org.schabi.newpipe.util.DependentPreferenceHelper
@@ -114,40 +109,53 @@ import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.NO_SERVICE_ID
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.PermissionHelper
+import org.schabi.newpipe.util.PermissionHelper.checkStoragePermissions
import org.schabi.newpipe.util.PlayButtonHelper
import org.schabi.newpipe.util.StreamTypeUtil
import org.schabi.newpipe.util.ThemeHelper
import org.schabi.newpipe.util.external_communication.KoreUtils
import org.schabi.newpipe.util.external_communication.ShareUtils
-import org.schabi.newpipe.util.image.CoilHelper
import org.schabi.newpipe.util.image.CoilHelper.loadAvatar
import org.schabi.newpipe.util.image.CoilHelper.loadDetailsThumbnail
import java.util.LinkedList
-import java.util.List
import java.util.Objects
import java.util.concurrent.TimeUnit
-import java.util.function.Consumer
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
class VideoDetailFragment :
-
BaseStateFragment(),
BackPressable,
PlayerServiceExtendedEventListener,
OnKeyDownListener {
+
+ // stream info
+ @JvmField @State var serviceId: Int = NO_SERVICE_ID
+ @JvmField @State var title: String = ""
+ @JvmField @State var url: String? = null
+ private var currentInfo: StreamInfo? = null
+
+ // player objects
+ private var playQueue: PlayQueue? = null
+ @JvmField @State var autoPlayEnabled: Boolean = true
+ private var playerService: PlayerService? = null
+ private var player: Player? = null
+
+ // views
+ // can't make this lateinit because it needs to be set to null when the view is destroyed
+ private var nullableBinding: FragmentVideoDetailBinding? = null
+ private val binding: FragmentVideoDetailBinding get() = nullableBinding!!
+ private lateinit var pageAdapter: TabAdapter
+ private var settingsContentObserver: ContentObserver? = null
+
// tabs
private var showComments = false
private var showRelatedItems = false
private var showDescription = false
- private var selectedTabTag: String? = null
-
- @AttrRes
- val tabIcons: MutableList = ArrayList()
-
- @StringRes
- val tabContentDescriptions: MutableList = ArrayList()
+ private lateinit var selectedTabTag: String
+ @AttrRes val tabIcons = ArrayList