1
0
Fork 0
mirror of https://github.com/TeamNewPipe/NewPipe.git synced 2025-10-03 01:39:38 +02:00

Merge branch 'dev' into Core-i18n

This commit is contained in:
Isira Seneviratne 2025-09-11 07:17:56 +05:30
commit 48ed95fc9b
26 changed files with 466 additions and 380 deletions

View file

@ -58,10 +58,7 @@ import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.StreamingService.LinkType; import org.schabi.newpipe.extractor.StreamingService.LinkType;
import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfo;
@ -253,7 +250,8 @@ public class RouterActivity extends AppCompatActivity {
showUnsupportedUrlDialog(url); showUnsupportedUrlDialog(url);
} }
}, throwable -> handleError(this, new ErrorInfo(throwable, }, throwable -> handleError(this, new ErrorInfo(throwable,
UserAction.SHARE_TO_NEWPIPE, "Getting service from url: " + url)))); UserAction.SHARE_TO_NEWPIPE, "Getting service from url: " + url,
null, url))));
} }
/** /**
@ -262,23 +260,19 @@ public class RouterActivity extends AppCompatActivity {
* @param errorInfo the error information * @param errorInfo the error information
*/ */
private static void handleError(final Context context, final ErrorInfo errorInfo) { private static void handleError(final Context context, final ErrorInfo errorInfo) {
if (errorInfo.getThrowable() != null) { if (errorInfo.getRecaptchaUrl() != null) {
errorInfo.getThrowable().printStackTrace();
}
if (errorInfo.getThrowable() instanceof ReCaptchaException) {
Toast.makeText(context, R.string.recaptcha_request_toast, Toast.LENGTH_LONG).show(); Toast.makeText(context, R.string.recaptcha_request_toast, Toast.LENGTH_LONG).show();
// Starting ReCaptcha Challenge Activity // Starting ReCaptcha Challenge Activity
final Intent intent = new Intent(context, ReCaptchaActivity.class); final Intent intent = new Intent(context, ReCaptchaActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.putExtra(ReCaptchaActivity.RECAPTCHA_URL_EXTRA, errorInfo.getRecaptchaUrl());
context.startActivity(intent); context.startActivity(intent);
} else if (errorInfo.getThrowable() instanceof ContentNotAvailableException } else if (errorInfo.isReportable()) {
|| errorInfo.getThrowable() instanceof ContentNotSupportedException) { ErrorUtil.createNotification(context, errorInfo);
} else {
// this exception does not usually indicate a problem that should be reported, // this exception does not usually indicate a problem that should be reported,
// so just show a toast instead of the notification // so just show a toast instead of the notification
Toast.makeText(context, errorInfo.getMessage(context), Toast.LENGTH_LONG).show(); Toast.makeText(context, errorInfo.getMessage(context), Toast.LENGTH_LONG).show();
} else {
ErrorUtil.createNotification(context, errorInfo);
} }
if (context instanceof RouterActivity) { if (context instanceof RouterActivity) {
@ -641,7 +635,8 @@ public class RouterActivity extends AppCompatActivity {
startActivity(intent); startActivity(intent);
finish(); finish();
}, throwable -> handleError(this, new ErrorInfo(throwable, }, throwable -> handleError(this, new ErrorInfo(throwable,
UserAction.SHARE_TO_NEWPIPE, "Starting info activity: " + currentUrl))) UserAction.SHARE_TO_NEWPIPE, "Starting info activity: " + currentUrl,
null, currentUrl)))
); );
return; return;
} }
@ -828,10 +823,10 @@ public class RouterActivity extends AppCompatActivity {
}) })
)), )),
throwable -> runOnVisible(ctx -> handleError(ctx, new ErrorInfo( throwable -> runOnVisible(ctx -> handleError(ctx, new ErrorInfo(
throwable, throwable, UserAction.REQUESTED_STREAM,
UserAction.REQUESTED_STREAM,
"Tried to add " + currentUrl + " to a playlist", "Tried to add " + currentUrl + " to a playlist",
((RouterActivity) ctx).currentService.getServiceId()) ((RouterActivity) ctx).currentService.getServiceId(),
currentUrl)
)) ))
) )
); );
@ -971,7 +966,7 @@ public class RouterActivity extends AppCompatActivity {
} }
}, throwable -> handleError(this, new ErrorInfo(throwable, finalUserAction, }, throwable -> handleError(this, new ErrorInfo(throwable, finalUserAction,
choice.url + " opened with " + choice.playerChoice, choice.url + " opened with " + choice.playerChoice,
choice.serviceId))); choice.serviceId, choice.url)));
} }
} }

View file

@ -389,8 +389,7 @@ public class DownloadDialog extends DialogFragment
} }
}, throwable -> ErrorUtil.showSnackbar(context, }, throwable -> ErrorUtil.showSnackbar(context,
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG, new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
"Downloading video stream size", "Downloading video stream size", currentInfo))));
currentInfo.getServiceId()))));
disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(getWrappedAudioStreams()) disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(getWrappedAudioStreams())
.subscribe(result -> { .subscribe(result -> {
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
@ -399,8 +398,7 @@ public class DownloadDialog extends DialogFragment
} }
}, throwable -> ErrorUtil.showSnackbar(context, }, throwable -> ErrorUtil.showSnackbar(context,
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG, new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
"Downloading audio stream size", "Downloading audio stream size", currentInfo))));
currentInfo.getServiceId()))));
disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedSubtitleStreams) disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedSubtitleStreams)
.subscribe(result -> { .subscribe(result -> {
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
@ -409,8 +407,7 @@ public class DownloadDialog extends DialogFragment
} }
}, throwable -> ErrorUtil.showSnackbar(context, }, throwable -> ErrorUtil.showSnackbar(context,
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG, new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
"Downloading subtitle stream size", "Downloading subtitle stream size", currentInfo))));
currentInfo.getServiceId()))));
} }
private void setupAudioTrackSpinner() { private void setupAudioTrackSpinner() {

View file

@ -36,8 +36,8 @@ public class AcraReportSender implements ReportSender {
ErrorUtil.openActivity(context, new ErrorInfo( ErrorUtil.openActivity(context, new ErrorInfo(
new String[]{report.getString(ReportField.STACK_TRACE)}, new String[]{report.getString(ReportField.STACK_TRACE)},
UserAction.UI_ERROR, UserAction.UI_ERROR,
null,
"ACRA report", "ACRA report",
null,
R.string.app_ui_crash)); R.string.app_ui_crash));
} }
} }

View file

@ -7,7 +7,6 @@ import androidx.core.content.ContextCompat
import com.google.android.exoplayer2.ExoPlaybackException import com.google.android.exoplayer2.ExoPlaybackException
import com.google.android.exoplayer2.upstream.HttpDataSource import com.google.android.exoplayer2.upstream.HttpDataSource
import com.google.android.exoplayer2.upstream.Loader import com.google.android.exoplayer2.upstream.Loader
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.schabi.newpipe.R import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.Info import org.schabi.newpipe.extractor.Info
@ -29,70 +28,108 @@ import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentExcepti
import org.schabi.newpipe.ktx.isNetworkRelated import org.schabi.newpipe.ktx.isNetworkRelated
import org.schabi.newpipe.player.mediasource.FailedMediaSource import org.schabi.newpipe.player.mediasource.FailedMediaSource
import org.schabi.newpipe.player.resolver.PlaybackResolver import org.schabi.newpipe.player.resolver.PlaybackResolver
import java.net.UnknownHostException
/**
* An error has occurred in the app. This class contains plain old parcelable data that can be used
* to report the error and to show it to the user along with correct action buttons.
*/
@Parcelize @Parcelize
class ErrorInfo private constructor( class ErrorInfo private constructor(
val stackTraces: Array<String>, val stackTraces: Array<String>,
val userAction: UserAction, val userAction: UserAction,
val serviceId: Int?,
val request: String, val request: String,
val serviceId: Int?,
private val message: ErrorMessage, private val message: ErrorMessage,
/**
* If `true`, a report button will be shown for this error. Otherwise the error is not something
* that can really be reported (e.g. a network issue, or content not being available at all).
*/
val isReportable: Boolean,
/**
* If `true`, the process causing this error can be retried, otherwise not.
*/
val isRetryable: Boolean,
/**
* If present, indicates that the exception was a ReCaptchaException, and this is the URL
* provided by the service that can be used to solve the ReCaptcha challenge.
*/
val recaptchaUrl: String?,
/**
* If present, this resource can alternatively be opened in browser (useful if NewPipe is
* badly broken).
*/
val openInBrowserUrl: String?,
) : Parcelable { ) : Parcelable {
// no need to store throwable, all data for report is in other variables @JvmOverloads
// also, the throwable might not be serializable, see TeamNewPipe/NewPipe#7302 constructor(
@IgnoredOnParcel
var throwable: Throwable? = null
private constructor(
throwable: Throwable, throwable: Throwable,
userAction: UserAction, userAction: UserAction,
serviceId: Int?, request: String,
request: String serviceId: Int? = null,
openInBrowserUrl: String? = null,
) : this( ) : this(
throwableToStringList(throwable), throwableToStringList(throwable),
userAction, userAction,
serviceId,
request, request,
getMessage(throwable, userAction, serviceId) serviceId,
) { getMessage(throwable, userAction, serviceId),
this.throwable = throwable isReportable(throwable),
} isRetryable(throwable),
(throwable as? ReCaptchaException)?.url,
openInBrowserUrl,
)
private constructor( @JvmOverloads
throwable: List<Throwable>, constructor(
throwables: List<Throwable>,
userAction: UserAction, userAction: UserAction,
serviceId: Int?, request: String,
request: String serviceId: Int? = null,
openInBrowserUrl: String? = null,
) : this( ) : this(
throwableListToStringList(throwable), throwableListToStringList(throwables),
userAction, userAction,
serviceId,
request, request,
getMessage(throwable.firstOrNull(), userAction, serviceId) serviceId,
) { getMessage(throwables.firstOrNull(), userAction, serviceId),
this.throwable = throwable.firstOrNull() throwables.any(::isReportable),
} throwables.isEmpty() || throwables.any(::isRetryable),
throwables.firstNotNullOfOrNull { it as? ReCaptchaException }?.url,
openInBrowserUrl,
)
// constructor to manually build ErrorInfo // constructor to manually build ErrorInfo when no throwable is available
constructor(stackTraces: Array<String>, userAction: UserAction, serviceId: Int?, request: String, @StringRes message: Int) : constructor(
this(stackTraces, userAction, serviceId, request, ErrorMessage(message)) stackTraces: Array<String>,
userAction: UserAction,
request: String,
serviceId: Int?,
@StringRes message: Int
) :
this(
stackTraces, userAction, request, serviceId, ErrorMessage(message),
true, false, null, null
)
// constructors with single throwable // constructor with only one throwable to extract service id and openInBrowserUrl from an Info
constructor(throwable: Throwable, userAction: UserAction, request: String) : constructor(
this(throwable, userAction, null, request) throwable: Throwable,
constructor(throwable: Throwable, userAction: UserAction, request: String, serviceId: Int) : userAction: UserAction,
this(throwable, userAction, serviceId, request) request: String,
constructor(throwable: Throwable, userAction: UserAction, request: String, info: Info?) : info: Info?,
this(throwable, userAction, info?.serviceId, request) ) :
this(throwable, userAction, request, info?.serviceId, info?.url)
// constructors with list of throwables // constructor with multiple throwables to extract service id and openInBrowserUrl from an Info
constructor(throwable: List<Throwable>, userAction: UserAction, request: String) : constructor(
this(throwable, userAction, null, request) throwables: List<Throwable>,
constructor(throwable: List<Throwable>, userAction: UserAction, request: String, serviceId: Int) : userAction: UserAction,
this(throwable, userAction, serviceId, request) request: String,
constructor(throwable: List<Throwable>, userAction: UserAction, request: String, info: Info?) : info: Info?,
this(throwable, userAction, info?.serviceId, request) ) :
this(throwables, userAction, request, info?.serviceId, info?.url)
fun getServiceName(): String { fun getServiceName(): String {
return getServiceName(serviceId) return getServiceName(serviceId)
@ -205,8 +242,7 @@ class ErrorInfo private constructor(
// other extractor exceptions // other extractor exceptions
throwable is ContentNotSupportedException -> throwable is ContentNotSupportedException ->
ErrorMessage(R.string.content_not_supported) ErrorMessage(R.string.content_not_supported)
// ReCaptchas should have already been handled elsewhere, // ReCaptchas will be handled in a special way anyway
// but return an error message here just in case
throwable is ReCaptchaException -> throwable is ReCaptchaException ->
ErrorMessage(R.string.recaptcha_request_toast) ErrorMessage(R.string.recaptcha_request_toast)
// test this at the end as many exceptions could be a subclass of IOException // test this at the end as many exceptions could be a subclass of IOException
@ -234,5 +270,36 @@ class ErrorInfo private constructor(
ErrorMessage(R.string.error_snackbar_message) ErrorMessage(R.string.error_snackbar_message)
} }
} }
fun isReportable(throwable: Throwable?): Boolean {
return when (throwable) {
// we don't have an exception, so this is a manually built error, which likely
// indicates that it's important and is thus reportable
null -> true
// the service explicitly said that content is not available (e.g. age restrictions,
// video deleted, etc.), there is no use in letting users report it
is ContentNotAvailableException -> false
// we know the content is not supported, no need to let the user report it
is ContentNotSupportedException -> false
// happens often when there is no internet connection; we don't use
// `throwable.isNetworkRelated` since any `IOException` would make that function
// return true, but not all `IOException`s are network related
is UnknownHostException -> false
// by default, this is an unexpected exception, which the user could report
else -> true
}
}
fun isRetryable(throwable: Throwable?): Boolean {
return when (throwable) {
// we know the content is not available, retrying won't help
is ContentNotAvailableException -> false
// we know the content is not supported, retrying won't help
is ContentNotSupportedException -> false
// by default (including if throwable is null), enable retrying (though the retry
// button will be shown only if a way to perform the retry is implemented)
else -> true
}
}
} }
} }

View file

@ -2,7 +2,6 @@ package org.schabi.newpipe.error
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.util.Log
import android.view.View import android.view.View
import android.widget.Button import android.widget.Button
import android.widget.TextView import android.widget.TextView
@ -14,11 +13,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.disposables.Disposable
import org.schabi.newpipe.MainActivity import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.R import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
import org.schabi.newpipe.ktx.animate import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.ktx.isInterruptedCaused
import org.schabi.newpipe.util.external_communication.ShareUtils import org.schabi.newpipe.util.external_communication.ShareUtils
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -68,50 +63,32 @@ class ErrorPanelHelper(
} }
fun showError(errorInfo: ErrorInfo) { fun showError(errorInfo: ErrorInfo) {
if (errorInfo.throwable != null && errorInfo.throwable!!.isInterruptedCaused) {
if (DEBUG) {
Log.w(TAG, "onError() isInterruptedCaused! = [$errorInfo.throwable]")
}
return
}
ensureDefaultVisibility() ensureDefaultVisibility()
errorTextView.text = errorInfo.getMessage(context)
if (errorInfo.throwable is ReCaptchaException) { if (errorInfo.recaptchaUrl != null) {
errorTextView.setText(R.string.recaptcha_request_toast) showAndSetErrorButtonAction(R.string.recaptcha_solve) {
showAndSetErrorButtonAction(
R.string.recaptcha_solve
) {
// Starting ReCaptcha Challenge Activity // Starting ReCaptcha Challenge Activity
val intent = Intent(context, ReCaptchaActivity::class.java) val intent = Intent(context, ReCaptchaActivity::class.java)
intent.putExtra( intent.putExtra(ReCaptchaActivity.RECAPTCHA_URL_EXTRA, errorInfo.recaptchaUrl)
ReCaptchaActivity.RECAPTCHA_URL_EXTRA,
(errorInfo.throwable as ReCaptchaException).url
)
fragment.startActivityForResult(intent, ReCaptchaActivity.RECAPTCHA_REQUEST) fragment.startActivityForResult(intent, ReCaptchaActivity.RECAPTCHA_REQUEST)
errorActionButton.setOnClickListener(null) errorActionButton.setOnClickListener(null)
} }
} else if (errorInfo.isReportable) {
errorRetryButton.isVisible = retryShouldBeShown showAndSetErrorButtonAction(R.string.error_snackbar_action) {
showAndSetOpenInBrowserButtonAction(errorInfo)
} else {
showAndSetErrorButtonAction(
R.string.error_snackbar_action
) {
ErrorUtil.openActivity(context, errorInfo) ErrorUtil.openActivity(context, errorInfo)
} }
}
errorTextView.text = errorInfo.getMessage(context) if (errorInfo.isRetryable) {
errorRetryButton.isVisible = retryShouldBeShown
}
if (errorInfo.throwable !is ContentNotAvailableException && if (errorInfo.openInBrowserUrl != null) {
errorInfo.throwable !is ContentNotSupportedException errorOpenInBrowserButton.isVisible = true
) { errorOpenInBrowserButton.setOnClickListener {
// show retry button only for content which is not unavailable or unsupported ShareUtils.openUrlInBrowser(context, errorInfo.openInBrowserUrl)
errorRetryButton.isVisible = retryShouldBeShown
} }
showAndSetOpenInBrowserButtonAction(errorInfo)
} }
setRootVisible() setRootVisible()
@ -129,15 +106,6 @@ class ErrorPanelHelper(
errorActionButton.setOnClickListener(listener) errorActionButton.setOnClickListener(listener)
} }
fun showAndSetOpenInBrowserButtonAction(
errorInfo: ErrorInfo
) {
errorOpenInBrowserButton.isVisible = true
errorOpenInBrowserButton.setOnClickListener {
ShareUtils.openUrlInBrowser(context, errorInfo.request)
}
}
fun showTextError(errorString: String) { fun showTextError(errorString: String) {
ensureDefaultVisibility() ensureDefaultVisibility()

View file

@ -93,6 +93,7 @@ import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.local.playlist.LocalPlaylistFragment; import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.PlayerIntentType;
import org.schabi.newpipe.player.PlayerService; import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.PlayerType; import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.event.OnKeyDownListener; import org.schabi.newpipe.player.event.OnKeyDownListener;
@ -876,7 +877,7 @@ public final class VideoDetailFragment
} }
} }
}, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_STREAM, }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_STREAM,
url == null ? "no url" : url, serviceId))); url == null ? "no url" : url, serviceId, url)));
} }
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
@ -1166,8 +1167,12 @@ public final class VideoDetailFragment
final PlayQueue queue = setupPlayQueueForIntent(false); final PlayQueue queue = setupPlayQueueForIntent(false);
tryAddVideoPlayerView(); tryAddVideoPlayerView();
final Intent playerIntent = NavigationHelper.getPlayerIntent(requireContext(), final Context context = requireContext();
PlayerService.class, queue, true, autoPlayEnabled); final Intent playerIntent =
NavigationHelper.getPlayerIntent(context, PlayerService.class, queue,
PlayerIntentType.AllOthers)
.putExtra(Player.PLAY_WHEN_READY, autoPlayEnabled)
.putExtra(Player.RESUME_PLAYBACK, true);
ContextCompat.startForegroundService(activity, playerIntent); ContextCompat.startForegroundService(activity, playerIntent);
} }
@ -1593,8 +1598,8 @@ public final class VideoDetailFragment
} }
if (!info.getErrors().isEmpty()) { if (!info.getErrors().isEmpty()) {
showSnackBarError(new ErrorInfo(info.getErrors(), showSnackBarError(new ErrorInfo(info.getErrors(), UserAction.REQUESTED_STREAM,
UserAction.REQUESTED_STREAM, info.getUrl(), info)); "Some info not extracted: " + info.getUrl(), info));
} }
} }

View file

@ -153,7 +153,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
handleResult(result); handleResult(result);
}, throwable -> }, throwable ->
showError(new ErrorInfo(throwable, errorUserAction, showError(new ErrorInfo(throwable, errorUserAction,
"Start loading: " + url, serviceId))); "Start loading: " + url, serviceId, url)));
} }
/** /**
@ -184,7 +184,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
handleNextItems(infoItemsPage); handleNextItems(infoItemsPage);
}, (@NonNull Throwable throwable) -> }, (@NonNull Throwable throwable) ->
dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(throwable, dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(throwable,
errorUserAction, "Loading more items: " + url, serviceId))); errorUserAction, "Loading more items: " + url, serviceId, url)));
} }
private void forbidDownwardFocusScroll() { private void forbidDownwardFocusScroll() {
@ -210,7 +210,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
if (!result.getErrors().isEmpty()) { if (!result.getErrors().isEmpty()) {
dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(result.getErrors(), errorUserAction, dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(result.getErrors(), errorUserAction,
"Get next items of: " + url, serviceId)); "Get next items of: " + url, serviceId, url));
} }
} }
@ -250,7 +250,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(result.getErrors(), dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(result.getErrors(),
errorUserAction, "Start loading: " + url, serviceId)); errorUserAction, "Start loading: " + url, serviceId, url));
} }
} }
} }

View file

@ -577,7 +577,7 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
isLoading.set(false); isLoading.set(false);
handleResult(result); handleResult(result);
}, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_CHANNEL, }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_CHANNEL,
url == null ? "No URL" : url, serviceId))); url == null ? "No URL" : url, serviceId, url)));
} }
@Override @Override

View file

@ -54,6 +54,7 @@ import org.schabi.newpipe.extractor.MetaInfo;
import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.Page;
import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.exceptions.ParsingException;
import org.schabi.newpipe.extractor.search.SearchExtractor; import org.schabi.newpipe.extractor.search.SearchExtractor;
import org.schabi.newpipe.extractor.search.SearchInfo; import org.schabi.newpipe.extractor.search.SearchInfo;
import org.schabi.newpipe.extractor.services.peertube.linkHandler.PeertubeSearchQueryHandlerFactory; import org.schabi.newpipe.extractor.services.peertube.linkHandler.PeertubeSearchQueryHandlerFactory;
@ -934,7 +935,21 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
infoListAdapter.clearStreamItemList(); infoListAdapter.clearStreamItemList();
showEmptyState(); showEmptyState();
} else { } else {
showError(new ErrorInfo(exception, UserAction.SEARCHED, searchString, serviceId)); showError(new ErrorInfo(exception, UserAction.SEARCHED, searchString, serviceId,
getOpenInBrowserUrlForErrors()));
}
}
@Nullable
private String getOpenInBrowserUrlForErrors() {
if (TextUtils.isEmpty(searchString)) {
return null;
}
try {
return service.getSearchQHFactory().getUrl(searchString,
Arrays.asList(contentFilter), sortFilter);
} catch (final NullPointerException | ParsingException ignored) {
return null;
} }
} }
@ -1022,7 +1037,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
&& !(exceptions.size() == 1 && !(exceptions.size() == 1
&& exceptions.get(0) instanceof SearchExtractor.NothingFoundException)) { && exceptions.get(0) instanceof SearchExtractor.NothingFoundException)) {
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED, showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
searchString, serviceId)); searchString, serviceId, getOpenInBrowserUrlForErrors()));
} }
searchSuggestion = result.getSearchSuggestion(); searchSuggestion = result.getSearchSuggestion();
@ -1095,13 +1110,14 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
// whose results are handled here, but let's check it anyway // whose results are handled here, but let's check it anyway
if (nextPage == null) { if (nextPage == null) {
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED, showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
"\"" + searchString + "\" → nextPage == null", serviceId)); "\"" + searchString + "\" → nextPage == null", serviceId,
getOpenInBrowserUrlForErrors()));
} else { } else {
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED, showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
"\"" + searchString + "\" → pageUrl: " + nextPage.getUrl() + ", " "\"" + searchString + "\" → pageUrl: " + nextPage.getUrl() + ", "
+ "pageIds: " + nextPage.getIds() + ", " + "pageIds: " + nextPage.getIds() + ", "
+ "pageCookies: " + nextPage.getCookies(), + "pageCookies: " + nextPage.getCookies(),
serviceId)); serviceId, getOpenInBrowserUrlForErrors()));
} }
} }

View file

@ -89,8 +89,8 @@ public class SubscriptionsImportFragment extends BaseFragment {
if (supportedSources.isEmpty() && currentServiceId != Constants.NO_SERVICE_ID) { if (supportedSources.isEmpty() && currentServiceId != Constants.NO_SERVICE_ID) {
ErrorUtil.showSnackbar(activity, ErrorUtil.showSnackbar(activity,
new ErrorInfo(new String[]{}, UserAction.SUBSCRIPTION_IMPORT_EXPORT, new ErrorInfo(new String[]{}, UserAction.SUBSCRIPTION_IMPORT_EXPORT,
currentServiceId,
"Service does not support importing subscriptions", "Service does not support importing subscriptions",
currentServiceId,
R.string.general_error)); R.string.general_error));
activity.finish(); activity.finish();
} }

View file

@ -24,12 +24,12 @@ import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK_ADJ
import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SKIP; import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SKIP;
import static com.google.android.exoplayer2.Player.DiscontinuityReason; import static com.google.android.exoplayer2.Player.DiscontinuityReason;
import static com.google.android.exoplayer2.Player.Listener; import static com.google.android.exoplayer2.Player.Listener;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF; import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE; import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE;
import static com.google.android.exoplayer2.Player.RepeatMode; import static com.google.android.exoplayer2.Player.RepeatMode;
import static org.schabi.newpipe.extractor.ServiceList.YouTube; import static org.schabi.newpipe.extractor.ServiceList.YouTube;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
import static org.schabi.newpipe.player.helper.PlayerHelper.nextRepeatMode;
import static org.schabi.newpipe.player.helper.PlayerHelper.retrievePlaybackParametersFromPrefs; import static org.schabi.newpipe.player.helper.PlayerHelper.retrievePlaybackParametersFromPrefs;
import static org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences; import static org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences;
import static org.schabi.newpipe.player.helper.PlayerHelper.savePlaybackParametersToPrefs; import static org.schabi.newpipe.player.helper.PlayerHelper.savePlaybackParametersToPrefs;
@ -61,6 +61,7 @@ import android.view.LayoutInflater;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import androidx.core.content.IntentCompat;
import androidx.core.math.MathUtils; import androidx.core.math.MathUtils;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
@ -109,6 +110,7 @@ import org.schabi.newpipe.player.playback.MediaSourceManager;
import org.schabi.newpipe.player.playback.PlaybackListener; import org.schabi.newpipe.player.playback.PlaybackListener;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.player.resolver.AudioPlaybackResolver; import org.schabi.newpipe.player.resolver.AudioPlaybackResolver;
import org.schabi.newpipe.player.resolver.VideoPlaybackResolver; import org.schabi.newpipe.player.resolver.VideoPlaybackResolver;
import org.schabi.newpipe.player.resolver.VideoPlaybackResolver.SourceType; import org.schabi.newpipe.player.resolver.VideoPlaybackResolver.SourceType;
@ -118,6 +120,7 @@ import org.schabi.newpipe.player.ui.PlayerUiList;
import org.schabi.newpipe.player.ui.PopupPlayerUi; import org.schabi.newpipe.player.ui.PopupPlayerUi;
import org.schabi.newpipe.player.ui.VideoPlayerUi; import org.schabi.newpipe.player.ui.VideoPlayerUi;
import org.schabi.newpipe.util.DependentPreferenceHelper; import org.schabi.newpipe.util.DependentPreferenceHelper;
import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.ListHelper;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.SerializedCache; import org.schabi.newpipe.util.SerializedCache;
@ -125,14 +128,17 @@ import org.schabi.newpipe.util.StreamTypeUtil;
import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.image.PicassoHelper;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.stream.IntStream; import java.util.stream.IntStream;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.disposables.SerialDisposable; import io.reactivex.rxjava3.disposables.SerialDisposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public final class Player implements PlaybackListener, Listener { public final class Player implements PlaybackListener, Listener {
public static final boolean DEBUG = MainActivity.DEBUG; public static final boolean DEBUG = MainActivity.DEBUG;
@ -154,15 +160,13 @@ public final class Player implements PlaybackListener, Listener {
// Intent // Intent
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
public static final String REPEAT_MODE = "repeat_mode";
public static final String PLAYBACK_QUALITY = "playback_quality"; public static final String PLAYBACK_QUALITY = "playback_quality";
public static final String PLAY_QUEUE_KEY = "play_queue_key"; public static final String PLAY_QUEUE_KEY = "play_queue_key";
public static final String ENQUEUE = "enqueue";
public static final String ENQUEUE_NEXT = "enqueue_next";
public static final String RESUME_PLAYBACK = "resume_playback"; public static final String RESUME_PLAYBACK = "resume_playback";
public static final String PLAY_WHEN_READY = "play_when_ready"; public static final String PLAY_WHEN_READY = "play_when_ready";
public static final String PLAYER_TYPE = "player_type"; public static final String PLAYER_TYPE = "player_type";
public static final String IS_MUTED = "is_muted"; public static final String PLAYER_INTENT_TYPE = "player_intent_type";
public static final String PLAYER_INTENT_DATA = "player_intent_data";
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Time constants // Time constants
@ -247,6 +251,8 @@ public final class Player implements PlaybackListener, Listener {
private final SerialDisposable progressUpdateDisposable = new SerialDisposable(); private final SerialDisposable progressUpdateDisposable = new SerialDisposable();
@NonNull @NonNull
private final CompositeDisposable databaseUpdateDisposable = new CompositeDisposable(); private final CompositeDisposable databaseUpdateDisposable = new CompositeDisposable();
@NonNull
private final CompositeDisposable streamItemDisposable = new CompositeDisposable();
// This is the only listener we need for thumbnail loading, since there is always at most only // This is the only listener we need for thumbnail loading, since there is always at most only
// one thumbnail being loaded at a time. This field is also here to maintain a strong reference, // one thumbnail being loaded at a time. This field is also here to maintain a strong reference,
@ -347,49 +353,121 @@ public final class Player implements PlaybackListener, Listener {
@SuppressWarnings("MethodLength") @SuppressWarnings("MethodLength")
public void handleIntent(@NonNull final Intent intent) { public void handleIntent(@NonNull final Intent intent) {
// fail fast if no play queue was provided final var playerIntentType = IntentCompat.getSerializableExtra(intent, PLAYER_INTENT_TYPE,
final String queueCache = intent.getStringExtra(PLAY_QUEUE_KEY); PlayerIntentType.class);
if (queueCache == null) { if (playerIntentType == null) {
return; return;
} }
final PlayQueue newQueue = SerializedCache.getInstance().take(queueCache, PlayQueue.class); // TODO: this should be in the second switch below, but Im not sure whether I
if (newQueue == null) { // can move the initUIs stuff without breaking the setup for edge cases somehow.
return; // when playing from a timestamp, keep the current player as-is.
if (playerIntentType != PlayerIntentType.TimestampChange) {
playerType = IntentCompat.getSerializableExtra(intent, PLAYER_TYPE, PlayerType.class);
} }
final PlayerType oldPlayerType = playerType;
playerType = PlayerType.retrieveFromIntent(intent);
initUIsForCurrentPlayerType(); initUIsForCurrentPlayerType();
// We need to setup audioOnly before super(), see "sourceOf"
isAudioOnly = audioPlayerSelected(); isAudioOnly = audioPlayerSelected();
if (intent.hasExtra(PLAYBACK_QUALITY)) { if (intent.hasExtra(PLAYBACK_QUALITY)) {
videoResolver.setPlaybackQuality(intent.getStringExtra(PLAYBACK_QUALITY)); videoResolver.setPlaybackQuality(intent.getStringExtra(PLAYBACK_QUALITY));
} }
// Resolve enqueue intents final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true);
if (intent.getBooleanExtra(ENQUEUE, false) && playQueue != null) {
playQueue.append(newQueue.getStreams());
return;
// Resolve enqueue next intents switch (playerIntentType) {
} else if (intent.getBooleanExtra(ENQUEUE_NEXT, false) && playQueue != null) { case Enqueue -> {
final int currentIndex = playQueue.getIndex(); if (playQueue != null) {
playQueue.append(newQueue.getStreams()); final PlayQueue newQueue = getPlayQueueFromCache(intent);
playQueue.move(playQueue.size() - 1, currentIndex + 1); if (newQueue == null) {
return;
}
playQueue.append(newQueue.getStreams());
return;
}
// TODO: This falls through to the old logic, there was no playQueue
// yet so we should start the player and add the new video
break;
}
case EnqueueNext -> {
if (playQueue != null) {
final PlayQueue newQueue = getPlayQueueFromCache(intent);
if (newQueue == null) {
return;
}
final PlayQueueItem newItem = newQueue.getStreams().get(0);
newQueue.enqueueNext(newItem, false);
return;
}
// TODO: This falls through to the old logic, there was no playQueue
// yet so we should start the player and add the new video
break;
}
case TimestampChange -> {
final var data = Objects.requireNonNull(IntentCompat.getParcelableExtra(intent,
PLAYER_INTENT_DATA, TimestampChangeData.class));
final Single<StreamInfo> single =
ExtractorHelper.getStreamInfo(data.getServiceId(), data.getUrl(), false);
streamItemDisposable.add(single.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(info -> {
final @Nullable PlayQueue oldPlayQueue = playQueue;
info.setStartPosition(data.getSeconds());
final PlayQueueItem playQueueItem = new PlayQueueItem(info);
// If the stream is already playing,
// we can just seek to the appropriate timestamp
if (oldPlayQueue != null
&& playQueueItem.isSameItem(oldPlayQueue.getItem())) {
// Player can have state = IDLE when playback is stopped or failed
// and we should retry in this case
if (simpleExoPlayer.getPlaybackState()
== com.google.android.exoplayer2.Player.STATE_IDLE) {
simpleExoPlayer.prepare();
}
simpleExoPlayer.seekTo(oldPlayQueue.getIndex(),
data.getSeconds() * 1000L);
simpleExoPlayer.setPlayWhenReady(playWhenReady);
} else {
final PlayQueue newPlayQueue;
// If there is no queue yet, just add our item
if (oldPlayQueue == null) {
newPlayQueue = new SinglePlayQueue(playQueueItem);
// else we add the timestamped stream behind the current video
// and start playing it.
} else {
oldPlayQueue.enqueueNext(playQueueItem, true);
oldPlayQueue.offsetIndex(1);
newPlayQueue = oldPlayQueue;
}
initPlayback(newPlayQueue, playWhenReady);
}
}, throwable -> {
// This will only show a snackbar if the passed context has a root view:
// otherwise it will resort to showing a notification, so we are safe
// here.
final var info = new ErrorInfo(throwable, UserAction.PLAY_ON_POPUP,
data.getUrl(), null, data.getUrl());
ErrorUtil.createNotification(context, info);
}));
return;
}
case AllOthers -> {
// fallthrough; TODO: put other intent data in separate cases
}
}
final PlayQueue newQueue = getPlayQueueFromCache(intent);
if (newQueue == null) {
return; return;
} }
final PlaybackParameters savedParameters = retrievePlaybackParametersFromPrefs(this); // branching parameters for below
final float playbackSpeed = savedParameters.speed;
final float playbackPitch = savedParameters.pitch;
final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString(
R.string.playback_skip_silence_key), getPlaybackSkipSilence());
final boolean samePlayQueue = playQueue != null && playQueue.equalStreamsAndIndex(newQueue); final boolean samePlayQueue = playQueue != null && playQueue.equalStreamsAndIndex(newQueue);
final int repeatMode = intent.getIntExtra(REPEAT_MODE, getRepeatMode());
final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true);
final boolean isMuted = intent.getBooleanExtra(IS_MUTED, isMuted());
/* /*
* TODO As seen in #7427 this does not work: * TODO As seen in #7427 this does not work:
@ -404,7 +482,7 @@ public final class Player implements PlaybackListener, Listener {
if (!exoPlayerIsNull() if (!exoPlayerIsNull()
&& newQueue.size() == 1 && newQueue.getItem() != null && newQueue.size() == 1 && newQueue.getItem() != null
&& playQueue != null && playQueue.size() == 1 && playQueue.getItem() != null && playQueue != null && playQueue.size() == 1 && playQueue.getItem() != null
&& newQueue.getItem().getUrl().equals(playQueue.getItem().getUrl()) && newQueue.getItem().isSameItem(playQueue.getItem())
&& newQueue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) { && newQueue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) {
// Player can have state = IDLE when playback is stopped or failed // Player can have state = IDLE when playback is stopped or failed
// and we should retry in this case // and we should retry in this case
@ -430,7 +508,8 @@ public final class Player implements PlaybackListener, Listener {
} else if (intent.getBooleanExtra(RESUME_PLAYBACK, false) } else if (intent.getBooleanExtra(RESUME_PLAYBACK, false)
&& DependentPreferenceHelper.getResumePlaybackEnabled(context) && DependentPreferenceHelper.getResumePlaybackEnabled(context)
&& !samePlayQueue // !samePlayQueue
&& (playQueue == null || !playQueue.equalStreamsAndIndex(newQueue))
&& !newQueue.isEmpty() && !newQueue.isEmpty()
&& newQueue.getItem() != null && newQueue.getItem() != null
&& newQueue.getItem().getRecoveryPosition() == PlayQueueItem.RECOVERY_UNSET) { && newQueue.getItem().getRecoveryPosition() == PlayQueueItem.RECOVERY_UNSET) {
@ -446,30 +525,30 @@ public final class Player implements PlaybackListener, Listener {
newQueue.setRecovery(newQueue.getIndex(), newQueue.setRecovery(newQueue.getIndex(),
state.getProgressMillis()); state.getProgressMillis());
} }
initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch, initPlayback(newQueue, playWhenReady);
playbackSkipSilence, playWhenReady, isMuted);
}, },
error -> { error -> {
if (DEBUG) { if (DEBUG) {
Log.w(TAG, "Failed to start playback", error); Log.w(TAG, "Failed to start playback", error);
} }
// In case any error we can start playback without history // In case any error we can start playback without history
initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch, initPlayback(newQueue, playWhenReady);
playbackSkipSilence, playWhenReady, isMuted);
}, },
() -> { () -> {
// Completed but not found in history // Completed but not found in history
initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch, initPlayback(newQueue, playWhenReady);
playbackSkipSilence, playWhenReady, isMuted);
} }
)); ));
} else { } else {
// Good to go... // Good to go...
// In a case of equal PlayQueues we can re-init old one but only when it is disposed // In a case of equal PlayQueues we can re-init old one but only when it is disposed
initPlayback(samePlayQueue ? playQueue : newQueue, repeatMode, playbackSpeed, initPlayback(samePlayQueue ? playQueue : newQueue, playWhenReady);
playbackPitch, playbackSkipSilence, playWhenReady, isMuted);
} }
}
public void handleIntentPost(final PlayerType oldPlayerType) {
if (oldPlayerType != playerType && playQueue != null) { if (oldPlayerType != playerType && playQueue != null) {
// If playerType changes from one to another we should reload the player // If playerType changes from one to another we should reload the player
// (to disable/enable video stream or to set quality) // (to disable/enable video stream or to set quality)
@ -480,6 +559,19 @@ public final class Player implements PlaybackListener, Listener {
NavigationHelper.sendPlayerStartedEvent(context); NavigationHelper.sendPlayerStartedEvent(context);
} }
@Nullable
private static PlayQueue getPlayQueueFromCache(@NonNull final Intent intent) {
final String queueCache = intent.getStringExtra(PLAY_QUEUE_KEY);
if (queueCache == null) {
return null;
}
final PlayQueue newQueue = SerializedCache.getInstance().take(queueCache, PlayQueue.class);
if (newQueue == null) {
return null;
}
return newQueue;
}
private void initUIsForCurrentPlayerType() { private void initUIsForCurrentPlayerType() {
if ((UIs.get(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN) if ((UIs.get(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN)
|| (UIs.get(PopupPlayerUi.class).isPresent() && playerType == PlayerType.POPUP)) { || (UIs.get(PopupPlayerUi.class).isPresent() && playerType == PlayerType.POPUP)) {
@ -513,16 +605,13 @@ public final class Player implements PlaybackListener, Listener {
} }
private void initPlayback(@NonNull final PlayQueue queue, private void initPlayback(@NonNull final PlayQueue queue,
@RepeatMode final int repeatMode, final boolean playOnReady) {
final float playbackSpeed,
final float playbackPitch,
final boolean playbackSkipSilence,
final boolean playOnReady,
final boolean isMuted) {
destroyPlayer(); destroyPlayer();
initPlayer(playOnReady); initPlayer(playOnReady);
setRepeatMode(repeatMode); final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString(
setPlaybackParameters(playbackSpeed, playbackPitch, playbackSkipSilence); R.string.playback_skip_silence_key), getPlaybackSkipSilence());
final PlaybackParameters savedParameters = retrievePlaybackParametersFromPrefs(this);
setPlaybackParameters(savedParameters.speed, savedParameters.pitch, playbackSkipSilence);
playQueue = queue; playQueue = queue;
playQueue.init(); playQueue.init();
@ -530,7 +619,7 @@ public final class Player implements PlaybackListener, Listener {
UIs.call(PlayerUi::initPlayback); UIs.call(PlayerUi::initPlayback);
simpleExoPlayer.setVolume(isMuted ? 0 : 1); simpleExoPlayer.setVolume(isMuted() ? 0 : 1);
notifyQueueUpdateToListeners(); notifyQueueUpdateToListeners();
} }
@ -612,6 +701,7 @@ public final class Player implements PlaybackListener, Listener {
databaseUpdateDisposable.clear(); databaseUpdateDisposable.clear();
progressUpdateDisposable.set(null); progressUpdateDisposable.set(null);
streamItemDisposable.clear();
cancelLoadingCurrentThumbnail(); cancelLoadingCurrentThumbnail();
UIs.destroyAll(Object.class); // destroy every UI: obviously every UI extends Object UIs.destroyAll(Object.class); // destroy every UI: obviously every UI extends Object
@ -1178,16 +1268,25 @@ public final class Player implements PlaybackListener, Listener {
return exoPlayerIsNull() ? REPEAT_MODE_OFF : simpleExoPlayer.getRepeatMode(); return exoPlayerIsNull() ? REPEAT_MODE_OFF : simpleExoPlayer.getRepeatMode();
} }
public void setRepeatMode(@RepeatMode final int repeatMode) { public void cycleNextRepeatMode() {
if (!exoPlayerIsNull()) { if (!exoPlayerIsNull()) {
@RepeatMode final int repeatMode;
switch (simpleExoPlayer.getRepeatMode()) {
case REPEAT_MODE_OFF:
repeatMode = REPEAT_MODE_ONE;
break;
case REPEAT_MODE_ONE:
repeatMode = REPEAT_MODE_ALL;
break;
case REPEAT_MODE_ALL:
default:
repeatMode = REPEAT_MODE_OFF;
break;
}
simpleExoPlayer.setRepeatMode(repeatMode); simpleExoPlayer.setRepeatMode(repeatMode);
} }
} }
public void cycleNextRepeatMode() {
setRepeatMode(nextRepeatMode(getRepeatMode()));
}
@Override @Override
public void onRepeatModeChanged(@RepeatMode final int repeatMode) { public void onRepeatModeChanged(@RepeatMode final int repeatMode) {
if (DEBUG) { if (DEBUG) {
@ -1290,7 +1389,8 @@ public final class Player implements PlaybackListener, Listener {
UserAction.PLAY_STREAM, UserAction.PLAY_STREAM,
"Loading failed for [" + currentMetadata.getTitle() "Loading failed for [" + currentMetadata.getTitle()
+ "]: " + currentMetadata.getStreamUrl(), + "]: " + currentMetadata.getStreamUrl(),
currentMetadata.getServiceId()); currentMetadata.getServiceId(),
currentMetadata.getStreamUrl());
ErrorUtil.createNotification(context, errorInfo); ErrorUtil.createNotification(context, errorInfo);
} }
@ -1506,7 +1606,7 @@ public final class Player implements PlaybackListener, Listener {
errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM, errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM,
"Player error[type=" + error.getErrorCodeName() "Player error[type=" + error.getErrorCodeName()
+ "] occurred while playing " + currentMetadata.getStreamUrl(), + "] occurred while playing " + currentMetadata.getStreamUrl(),
currentMetadata.getServiceId()); currentMetadata.getServiceId(), currentMetadata.getStreamUrl());
} }
ErrorUtil.createNotification(context, errorInfo); ErrorUtil.createNotification(context, errorInfo);
} }

View file

@ -0,0 +1,24 @@
package org.schabi.newpipe.player
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
// We model this as an enum class plus one struct for each enum value
// so we can consume it from Java properly. After converting to Kotlin,
// we could switch to a sealed enum class & a proper Kotlin `when` match.
enum class PlayerIntentType {
Enqueue,
EnqueueNext,
TimestampChange,
AllOthers
}
/**
* A timestamp on the given was clicked and we should switch the playing stream to it.
*/
@Parcelize
data class TimestampChangeData(
val serviceId: Int,
val url: String,
val seconds: Int
) : Parcelable

View file

@ -169,7 +169,9 @@ public final class PlayerService extends MediaBrowserServiceCompat {
} }
if (player != null) { if (player != null) {
final PlayerType oldPlayerType = player.getPlayerType();
player.handleIntent(intent); player.handleIntent(intent);
player.handleIntentPost(oldPlayerType);
player.UIs().get(MediaSessionPlayerUi.class) player.UIs().get(MediaSessionPlayerUi.class)
.ifPresent(ui -> ui.handleMediaButtonIntent(intent)); .ifPresent(ui -> ui.handleMediaButtonIntent(intent));
} }

View file

@ -1,32 +1,7 @@
package org.schabi.newpipe.player; package org.schabi.newpipe.player;
import static org.schabi.newpipe.player.Player.PLAYER_TYPE;
import android.content.Intent;
public enum PlayerType { public enum PlayerType {
MAIN, MAIN,
AUDIO, AUDIO,
POPUP; POPUP;
/**
* @return an integer representing this {@link PlayerType}, to be used to save it in intents
* @see #retrieveFromIntent(Intent) Use retrieveFromIntent() to retrieve and convert player type
* integers from an intent
*/
public int valueForIntent() {
return ordinal();
}
/**
* @param intent the intent to retrieve a player type from
* @return the player type integer retrieved from the intent, converted back into a {@link
* PlayerType}, or {@link PlayerType#MAIN} if there is no player type extra in the
* intent
* @throws ArrayIndexOutOfBoundsException if the intent contains an invalid player type integer
* @see #valueForIntent() Use valueForIntent() to obtain valid player type integers
*/
public static PlayerType retrieveFromIntent(final Intent intent) {
return values()[intent.getIntExtra(PLAYER_TYPE, MAIN.valueForIntent())];
}
} }

View file

@ -1,8 +1,5 @@
package org.schabi.newpipe.player.helper; package org.schabi.newpipe.player.helper;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE;
import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_ALWAYS; import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_ALWAYS;
import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_NEVER; import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_NEVER;
import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_WIFI; import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_WIFI;
@ -25,7 +22,6 @@ import androidx.core.content.ContextCompat;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.Player.RepeatMode;
import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.SeekParameters;
import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.source.ProgressiveMediaSource;
import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection;
@ -410,23 +406,9 @@ public final class PlayerHelper {
return singlePlayQueue; return singlePlayQueue;
} }
// endregion // endregion
// region Utils used by player // region Utils used by player
@RepeatMode
public static int nextRepeatMode(@RepeatMode final int repeatMode) {
switch (repeatMode) {
case REPEAT_MODE_OFF:
return REPEAT_MODE_ONE;
case REPEAT_MODE_ONE:
return REPEAT_MODE_ALL;
case REPEAT_MODE_ALL:
default:
return REPEAT_MODE_OFF;
}
}
@ResizeMode @ResizeMode
public static int retrieveResizeModeFromPrefs(final Player player) { public static int retrieveResizeModeFromPrefs(final Player player) {
return player.getPrefs().getInt(player.getContext().getString(R.string.last_resize_mode), return player.getPrefs().getInt(player.getContext().getString(R.string.last_resize_mode),

View file

@ -23,6 +23,7 @@ import androidx.core.content.ContextCompat;
import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.PlayerIntentType;
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi; import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
@ -254,7 +255,9 @@ public final class NotificationUtil {
} else { } else {
// We are playing in fragment. Don't open another activity just show fragment. That's it // We are playing in fragment. Don't open another activity just show fragment. That's it
final Intent intent = NavigationHelper.getPlayerIntent( final Intent intent = NavigationHelper.getPlayerIntent(
player.getContext(), MainActivity.class, null, true); player.getContext(), MainActivity.class, null,
PlayerIntentType.AllOthers);
intent.putExtra(Player.RESUME_PLAYBACK, true);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.setAction(Intent.ACTION_MAIN); intent.setAction(Intent.ACTION_MAIN);
intent.addCategory(Intent.CATEGORY_LAUNCHER); intent.addCategory(Intent.CATEGORY_LAUNCHER);

View file

@ -291,6 +291,22 @@ public abstract class PlayQueue implements Serializable {
broadcast(new AppendEvent(itemList.size())); broadcast(new AppendEvent(itemList.size()));
} }
/**
* Add the given item after the current stream.
*
* @param item item to add.
* @param skipIfSame if set, skip adding if the next stream is the same stream.
*/
public void enqueueNext(@NonNull final PlayQueueItem item, final boolean skipIfSame) {
final int currentIndex = getIndex();
// if the next item is the same item as the one we want to enqueue, skip if flag is true
if (skipIfSame && item.isSameItem(getItem(currentIndex + 1))) {
return;
}
append(List.of(item));
move(size() - 1, currentIndex + 1);
}
/** /**
* Removes the item at the given index from the play queue. * Removes the item at the given index from the play queue.
* <p> * <p>
@ -529,8 +545,7 @@ public abstract class PlayQueue implements Serializable {
final PlayQueueItem stream = streams.get(i); final PlayQueueItem stream = streams.get(i);
final PlayQueueItem otherStream = other.streams.get(i); final PlayQueueItem otherStream = other.streams.get(i);
// Check is based on serviceId and URL // Check is based on serviceId and URL
if (stream.getServiceId() != otherStream.getServiceId() if (!stream.isSameItem(otherStream)) {
|| !stream.getUrl().equals(otherStream.getUrl())) {
return false; return false;
} }
} }

View file

@ -38,7 +38,7 @@ public class PlayQueueItem implements Serializable {
private long recoveryPosition; private long recoveryPosition;
private Throwable error; private Throwable error;
PlayQueueItem(@NonNull final StreamInfo info) { public PlayQueueItem(@NonNull final StreamInfo info) {
this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(), this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(),
info.getThumbnails(), info.getUploaderName(), info.getThumbnails(), info.getUploaderName(),
info.getUploaderUrl(), info.getStreamType()); info.getUploaderUrl(), info.getStreamType());
@ -71,6 +71,22 @@ public class PlayQueueItem implements Serializable {
this.recoveryPosition = RECOVERY_UNSET; this.recoveryPosition = RECOVERY_UNSET;
} }
/** Whether these two items should be treated as the same stream
* for the sake of keeping the same player running when e.g. jumping between timestamps.
*
* @param other the {@link PlayQueueItem} to compare against.
* @return whether the two items are the same so the stream can be re-used.
*/
public boolean isSameItem(@Nullable final PlayQueueItem other) {
if (other == null) {
return false;
}
// We assume that the same service & URL uniquely determines
// that we can keep the same stream running.
return serviceId == other.serviceId
&& url.equals(other.url);
}
@NonNull @NonNull
public String getTitle() { public String getTitle() {
return title; return title;

View file

@ -16,7 +16,9 @@ public final class SinglePlayQueue extends PlayQueue {
public SinglePlayQueue(final StreamInfo info) { public SinglePlayQueue(final StreamInfo info) {
super(0, List.of(new PlayQueueItem(info))); super(0, List.of(new PlayQueueItem(info)));
} }
public SinglePlayQueue(final PlayQueueItem item) {
super(0, List.of(item));
}
public SinglePlayQueue(final StreamInfo info, final long startPosition) { public SinglePlayQueue(final StreamInfo info, final long startPosition) {
super(0, List.of(new PlayQueueItem(info))); super(0, List.of(new PlayQueueItem(info)));
getItem().setRecoveryPosition(startPosition); getItem().setRecoveryPosition(startPosition);

View file

@ -57,8 +57,10 @@ import org.schabi.newpipe.local.subscription.SubscriptionFragment;
import org.schabi.newpipe.local.subscription.SubscriptionsImportFragment; import org.schabi.newpipe.local.subscription.SubscriptionsImportFragment;
import org.schabi.newpipe.player.PlayQueueActivity; import org.schabi.newpipe.player.PlayQueueActivity;
import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.PlayerIntentType;
import org.schabi.newpipe.player.PlayerService; import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.PlayerType; import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.TimestampChangeData;
import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.player.helper.PlayerHelper;
import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
@ -67,6 +69,7 @@ import org.schabi.newpipe.settings.SettingsActivity;
import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.List; import java.util.List;
import java.util.Optional;
public final class NavigationHelper { public final class NavigationHelper {
public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag"; public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag";
@ -85,54 +88,32 @@ public final class NavigationHelper {
public static <T> Intent getPlayerIntent(@NonNull final Context context, public static <T> Intent getPlayerIntent(@NonNull final Context context,
@NonNull final Class<T> targetClazz, @NonNull final Class<T> targetClazz,
@Nullable final PlayQueue playQueue, @Nullable final PlayQueue playQueue,
final boolean resumePlayback) { @NonNull final PlayerIntentType playerIntentType) {
final Intent intent = new Intent(context, targetClazz); final String cacheKey = Optional.ofNullable(playQueue)
.map(queue -> SerializedCache.getInstance().put(queue, PlayQueue.class))
if (playQueue != null) { .orElse(null);
final String cacheKey = SerializedCache.getInstance().put(playQueue, PlayQueue.class); return new Intent(context, targetClazz)
if (cacheKey != null) { .putExtra(Player.PLAY_QUEUE_KEY, cacheKey)
intent.putExtra(Player.PLAY_QUEUE_KEY, cacheKey); .putExtra(Player.PLAYER_TYPE, PlayerType.MAIN)
} .putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true)
} .putExtra(Player.PLAYER_INTENT_TYPE, playerIntentType);
intent.putExtra(Player.PLAYER_TYPE, PlayerType.MAIN.valueForIntent());
intent.putExtra(Player.RESUME_PLAYBACK, resumePlayback);
intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true);
return intent;
} }
@NonNull @NonNull
public static <T> Intent getPlayerIntent(@NonNull final Context context, public static Intent getPlayerTimestampIntent(@NonNull final Context context,
@NonNull final Class<T> targetClazz, @NonNull final TimestampChangeData data) {
@Nullable final PlayQueue playQueue, return new Intent(context, PlayerService.class)
final boolean resumePlayback, .putExtra(Player.PLAYER_INTENT_TYPE, PlayerIntentType.TimestampChange)
final boolean playWhenReady) { .putExtra(Player.PLAYER_INTENT_DATA, data);
return getPlayerIntent(context, targetClazz, playQueue, resumePlayback)
.putExtra(Player.PLAY_WHEN_READY, playWhenReady);
}
@NonNull
public static <T> Intent getPlayerEnqueueIntent(@NonNull final Context context,
@NonNull final Class<T> targetClazz,
@Nullable final PlayQueue playQueue) {
// when enqueueing `resumePlayback` is always `false` since:
// - if there is a video already playing, the value of `resumePlayback` just doesn't make
// any difference.
// - if there is nothing already playing, it is useful for the enqueue action to have a
// slightly different behaviour than the normal play action: the latter resumes playback,
// the former doesn't. (note that enqueue can be triggered when nothing is playing only
// by long pressing the video detail fragment, playlist or channel controls
return getPlayerIntent(context, targetClazz, playQueue, false)
.putExtra(Player.ENQUEUE, true);
} }
@NonNull @NonNull
public static <T> Intent getPlayerEnqueueNextIntent(@NonNull final Context context, public static <T> Intent getPlayerEnqueueNextIntent(@NonNull final Context context,
@NonNull final Class<T> targetClazz, @NonNull final Class<T> targetClazz,
@Nullable final PlayQueue playQueue) { @Nullable final PlayQueue playQueue) {
// see comment in `getPlayerEnqueueIntent` as to why `resumePlayback` is false return getPlayerIntent(context, targetClazz, playQueue, PlayerIntentType.EnqueueNext)
return getPlayerIntent(context, targetClazz, playQueue, false) // see comment in `getPlayerEnqueueIntent` as to why `resumePlayback` is false
.putExtra(Player.ENQUEUE_NEXT, true); .putExtra(Player.RESUME_PLAYBACK, false);
} }
/* PLAY */ /* PLAY */
@ -166,8 +147,10 @@ public final class NavigationHelper {
Toast.makeText(context, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show(); Toast.makeText(context, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show();
final Intent intent = getPlayerIntent(context, PlayerService.class, queue, resumePlayback); final var intent = getPlayerIntent(context, PlayerService.class, queue,
intent.putExtra(Player.PLAYER_TYPE, PlayerType.POPUP.valueForIntent()); PlayerIntentType.AllOthers)
.putExtra(Player.PLAYER_TYPE, PlayerType.POPUP)
.putExtra(Player.RESUME_PLAYBACK, resumePlayback);
ContextCompat.startForegroundService(context, intent); ContextCompat.startForegroundService(context, intent);
} }
@ -177,8 +160,10 @@ public final class NavigationHelper {
Toast.makeText(context, R.string.background_player_playing_toast, Toast.LENGTH_SHORT) Toast.makeText(context, R.string.background_player_playing_toast, Toast.LENGTH_SHORT)
.show(); .show();
final Intent intent = getPlayerIntent(context, PlayerService.class, queue, resumePlayback); final Intent intent = getPlayerIntent(context, PlayerService.class, queue,
intent.putExtra(Player.PLAYER_TYPE, PlayerType.AUDIO.valueForIntent()); PlayerIntentType.AllOthers)
.putExtra(Player.PLAYER_TYPE, PlayerType.AUDIO)
.putExtra(Player.RESUME_PLAYBACK, resumePlayback);
ContextCompat.startForegroundService(context, intent); ContextCompat.startForegroundService(context, intent);
} }
@ -191,9 +176,18 @@ public final class NavigationHelper {
} }
Toast.makeText(context, R.string.enqueued, Toast.LENGTH_SHORT).show(); Toast.makeText(context, R.string.enqueued, Toast.LENGTH_SHORT).show();
final Intent intent = getPlayerEnqueueIntent(context, PlayerService.class, queue);
intent.putExtra(Player.PLAYER_TYPE, playerType.valueForIntent()); // when enqueueing `resumePlayback` is always `false` since:
// - if there is a video already playing, the value of `resumePlayback` just doesn't make
// any difference.
// - if there is nothing already playing, it is useful for the enqueue action to have a
// slightly different behaviour than the normal play action: the latter resumes playback,
// the former doesn't. (note that enqueue can be triggered when nothing is playing only
// by long pressing the video detail fragment, playlist or channel controls
final Intent intent = getPlayerIntent(context, PlayerService.class, queue,
PlayerIntentType.Enqueue)
.putExtra(Player.RESUME_PLAYBACK, false)
.putExtra(Player.PLAYER_TYPE, playerType);
ContextCompat.startForegroundService(context, intent); ContextCompat.startForegroundService(context, intent);
} }
@ -215,9 +209,8 @@ public final class NavigationHelper {
playerType = PlayerType.AUDIO; playerType = PlayerType.AUDIO;
} }
Toast.makeText(context, R.string.enqueued_next, Toast.LENGTH_SHORT).show(); Toast.makeText(context, R.string.enqueued_next, Toast.LENGTH_SHORT).show();
final Intent intent = getPlayerEnqueueNextIntent(context, PlayerService.class, queue); final Intent intent = getPlayerEnqueueNextIntent(context, PlayerService.class, queue)
.putExtra(Player.PLAYER_TYPE, playerType);
intent.putExtra(Player.PLAYER_TYPE, playerType.valueForIntent());
ContextCompat.startForegroundService(context, intent); ContextCompat.startForegroundService(context, intent);
} }

View file

@ -121,7 +121,7 @@ public final class SparseItemUtil {
callback.accept(result); callback.accept(result);
}, throwable -> ErrorUtil.createNotification(context, }, throwable -> ErrorUtil.createNotification(context,
new ErrorInfo(throwable, UserAction.REQUESTED_STREAM, new ErrorInfo(throwable, UserAction.REQUESTED_STREAM,
"Loading stream info: " + url, serviceId) "Loading stream info: " + url, serviceId, url)
)); ));
} }
} }

View file

@ -1,63 +1,27 @@
package org.schabi.newpipe.util.text; package org.schabi.newpipe.util.text;
import android.content.Context; import android.content.Context;
import android.content.Intent;
import androidx.core.content.ContextCompat;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.exceptions.ParsingException; import org.schabi.newpipe.extractor.exceptions.ParsingException;
import org.schabi.newpipe.extractor.linkhandler.LinkHandlerFactory; import org.schabi.newpipe.extractor.linkhandler.LinkHandlerFactory;
import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.player.TimestampChangeData;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public final class InternalUrlsHandler { public final class InternalUrlsHandler {
private static final String TAG = InternalUrlsHandler.class.getSimpleName();
private static final boolean DEBUG = MainActivity.DEBUG;
private static final Pattern AMPERSAND_TIMESTAMP_PATTERN = Pattern.compile("(.*)&t=(\\d+)"); private static final Pattern AMPERSAND_TIMESTAMP_PATTERN = Pattern.compile("(.*)&t=(\\d+)");
private static final Pattern HASHTAG_TIMESTAMP_PATTERN =
Pattern.compile("(.*)#timestamp=(\\d+)");
private InternalUrlsHandler() { private InternalUrlsHandler() {
} }
/**
* Handle a YouTube timestamp comment URL in NewPipe.
* <p>
* This method will check if the provided url is a YouTube comment description URL ({@code
* https://www.youtube.com/watch?v=}video_id{@code #timestamp=}time_in_seconds). If yes, the
* popup player will be opened when the user will click on the timestamp in the comment,
* at the time and for the video indicated in the timestamp.
*
* @param disposables a field of the Activity/Fragment class that calls this method
* @param context the context to use
* @param url the URL to check if it can be handled
* @return true if the URL can be handled by NewPipe, false if it cannot
*/
public static boolean handleUrlCommentsTimestamp(@NonNull final CompositeDisposable
disposables,
final Context context,
@NonNull final String url) {
return handleUrl(context, url, HASHTAG_TIMESTAMP_PATTERN, disposables);
}
/** /**
* Handle a YouTube timestamp description URL in NewPipe. * Handle a YouTube timestamp description URL in NewPipe.
* <p> * <p>
@ -66,36 +30,13 @@ public final class InternalUrlsHandler {
* player will be opened when the user will click on the timestamp in the video description, * player will be opened when the user will click on the timestamp in the video description,
* at the time and for the video indicated in the timestamp. * at the time and for the video indicated in the timestamp.
* *
* @param disposables a field of the Activity/Fragment class that calls this method
* @param context the context to use * @param context the context to use
* @param url the URL to check if it can be handled * @param url the URL to check if it can be handled
* @return true if the URL can be handled by NewPipe, false if it cannot * @return true if the URL can be handled by NewPipe, false if it cannot
*/ */
public static boolean handleUrlDescriptionTimestamp(@NonNull final CompositeDisposable public static boolean handleUrlDescriptionTimestamp(final Context context,
disposables,
final Context context,
@NonNull final String url) { @NonNull final String url) {
return handleUrl(context, url, AMPERSAND_TIMESTAMP_PATTERN, disposables); final Matcher matcher = AMPERSAND_TIMESTAMP_PATTERN.matcher(url);
}
/**
* Handle an URL in NewPipe.
* <p>
* This method will check if the provided url can be handled in NewPipe or not. If this is a
* service URL with a timestamp, the popup player will be opened and true will be returned;
* else, false will be returned.
*
* @param context the context to use
* @param url the URL to check if it can be handled
* @param pattern the pattern to use
* @param disposables a field of the Activity/Fragment class that calls this method
* @return true if the URL can be handled by NewPipe, false if it cannot
*/
private static boolean handleUrl(final Context context,
@NonNull final String url,
@NonNull final Pattern pattern,
@NonNull final CompositeDisposable disposables) {
final Matcher matcher = pattern.matcher(url);
if (!matcher.matches()) { if (!matcher.matches()) {
return false; return false;
} }
@ -120,7 +61,7 @@ public final class InternalUrlsHandler {
} }
if (linkType == StreamingService.LinkType.STREAM && seconds != -1) { if (linkType == StreamingService.LinkType.STREAM && seconds != -1) {
return playOnPopup(context, matchedUrl, service, seconds, disposables); return playOnPopup(context, matchedUrl, service, seconds);
} else { } else {
NavigationHelper.openRouterActivity(context, matchedUrl); NavigationHelper.openRouterActivity(context, matchedUrl);
return true; return true;
@ -134,15 +75,12 @@ public final class InternalUrlsHandler {
* @param url the URL of the content * @param url the URL of the content
* @param service the service of the content * @param service the service of the content
* @param seconds the position in seconds at which the floating player will start * @param seconds the position in seconds at which the floating player will start
* @param disposables disposables created by the method are added here and their lifecycle
* should be handled by the calling class
* @return true if the playback of the content has successfully started or false if not * @return true if the playback of the content has successfully started or false if not
*/ */
public static boolean playOnPopup(final Context context, public static boolean playOnPopup(final Context context,
final String url, final String url,
@NonNull final StreamingService service, @NonNull final StreamingService service,
final int seconds, final int seconds) {
@NonNull final CompositeDisposable disposables) {
final LinkHandlerFactory factory = service.getStreamLHFactory(); final LinkHandlerFactory factory = service.getStreamLHFactory();
final String cleanUrl; final String cleanUrl;
@ -152,19 +90,14 @@ public final class InternalUrlsHandler {
return false; return false;
} }
final Single<StreamInfo> single = final Intent intent = NavigationHelper.getPlayerTimestampIntent(context,
ExtractorHelper.getStreamInfo(service.getServiceId(), cleanUrl, false); new TimestampChangeData(
disposables.add(single.subscribeOn(Schedulers.io()) service.getServiceId(),
.observeOn(AndroidSchedulers.mainThread()) cleanUrl,
.subscribe(info -> { seconds
final PlayQueue playQueue = new SinglePlayQueue(info, seconds * 1000L); ));
NavigationHelper.playOnPopupPlayer(context, playQueue, false); ContextCompat.startForegroundService(context, intent);
}, throwable -> {
final var errorInfo = new ErrorInfo(throwable, UserAction.PLAY_ON_POPUP, url);
// This will only show a snackbar if the passed context has a root view:
// otherwise it will resort to showing a notification, so we are safe here.
ErrorUtil.showSnackbar(context, errorInfo);
}));
return true; return true;
} }
} }

View file

@ -192,7 +192,7 @@ public final class TextLinkifier {
* <p> * <p>
* Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of * Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of
* a content, this method will parse the {@link CharSequence} and replace all current web links * a content, this method will parse the {@link CharSequence} and replace all current web links
* with {@link ShareUtils#openUrlInBrowser(Context, String, boolean)}. * with {@link ShareUtils#openUrlInBrowser(Context, String)}.
* </p> * </p>
* *
* <p> * <p>
@ -240,7 +240,7 @@ public final class TextLinkifier {
for (final URLSpan span : urls) { for (final URLSpan span : urls) {
final String url = span.getURL(); final String url = span.getURL();
final LongPressClickableSpan longPressClickableSpan = final LongPressClickableSpan longPressClickableSpan =
new UrlLongPressClickableSpan(context, disposables, url); new UrlLongPressClickableSpan(context, url);
textBlockLinked.setSpan(longPressClickableSpan, textBlockLinked.setSpan(longPressClickableSpan,
textBlockLinked.getSpanStart(span), textBlockLinked.getSpanStart(span),

View file

@ -46,7 +46,7 @@ final class TimestampLongPressClickableSpan extends LongPressClickableSpan {
@Override @Override
public void onClick(@NonNull final View view) { public void onClick(@NonNull final View view) {
playOnPopup(context, relatedStreamUrl, relatedInfoService, playOnPopup(context, relatedStreamUrl, relatedInfoService,
timestampMatchDTO.seconds(), disposables); timestampMatchDTO.seconds());
} }
@Override @Override

View file

@ -7,29 +7,22 @@ import androidx.annotation.NonNull;
import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.external_communication.ShareUtils;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
final class UrlLongPressClickableSpan extends LongPressClickableSpan { final class UrlLongPressClickableSpan extends LongPressClickableSpan {
@NonNull @NonNull
private final Context context; private final Context context;
@NonNull @NonNull
private final CompositeDisposable disposables;
@NonNull
private final String url; private final String url;
UrlLongPressClickableSpan(@NonNull final Context context, UrlLongPressClickableSpan(@NonNull final Context context,
@NonNull final CompositeDisposable disposables,
@NonNull final String url) { @NonNull final String url) {
this.context = context; this.context = context;
this.disposables = disposables;
this.url = url; this.url = url;
} }
@Override @Override
public void onClick(@NonNull final View view) { public void onClick(@NonNull final View view) {
if (!InternalUrlsHandler.handleUrlDescriptionTimestamp( if (!InternalUrlsHandler.handleUrlDescriptionTimestamp(context, url)) {
disposables, context, url)) {
ShareUtils.openUrlInApp(context, url); ShareUtils.openUrlInApp(context, url);
} }
} }

View file

@ -572,7 +572,7 @@ public class MissionAdapter extends Adapter<ViewHolder> implements Handler.Callb
ErrorUtil.createNotification(mContext, ErrorUtil.createNotification(mContext,
new ErrorInfo(ErrorInfo.Companion.throwableToStringList(mission.errObject), action, new ErrorInfo(ErrorInfo.Companion.throwableToStringList(mission.errObject), action,
service, request.toString(), reason)); request.toString(), service, reason));
} }
public void clearFinishedDownloads(boolean delete) { public void clearFinishedDownloads(boolean delete) {