1
mirror of https://github.com/TeamNewPipe/NewPipe synced 2025-09-21 22:20:50 +02:00

Compare commits

..

1 Commits

Author SHA1 Message Date
Fynn Godau
83a0abddcc Fix and simplify openUrlInBrowser
The code was not previously working in case no default browser is set[1]
AND NewPipe is set as default handler for the link in question.

We improve it by telling the system to choose the target app as if the
URI was `http://`, which works even if the user has not set a default
browser.

[1]: also the case if platform refuses to tell an app what the user's
default browser is, which I observed on CalyxOS.
2025-09-05 17:49:58 +02:00
20 changed files with 294 additions and 375 deletions

View File

@@ -93,7 +93,6 @@ 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.PlayerIntentType;
import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.event.OnKeyDownListener;
@@ -1167,12 +1166,8 @@ public final class VideoDetailFragment
final PlayQueue queue = setupPlayQueueForIntent(false);
tryAddVideoPlayerView();
final Context context = requireContext();
final Intent playerIntent =
NavigationHelper.getPlayerIntent(context, PlayerService.class, queue,
PlayerIntentType.AllOthers)
.putExtra(Player.PLAY_WHEN_READY, autoPlayEnabled)
.putExtra(Player.RESUME_PLAYBACK, true);
final Intent playerIntent = NavigationHelper.getPlayerIntent(requireContext(),
PlayerService.class, queue, true, autoPlayEnabled);
ContextCompat.startForegroundService(activity, playerIntent);
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,7 +1,32 @@
package org.schabi.newpipe.player;
import static org.schabi.newpipe.player.Player.PLAYER_TYPE;
import android.content.Intent;
public enum PlayerType {
MAIN,
AUDIO,
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,5 +1,8 @@
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_NEVER;
import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_WIFI;
@@ -22,6 +25,7 @@ import androidx.core.content.ContextCompat;
import androidx.preference.PreferenceManager;
import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.Player.RepeatMode;
import com.google.android.exoplayer2.SeekParameters;
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection;
@@ -406,9 +410,23 @@ public final class PlayerHelper {
return singlePlayQueue;
}
// endregion
// 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
public static int retrieveResizeModeFromPrefs(final Player player) {
return player.getPrefs().getInt(player.getContext().getString(R.string.last_resize_mode),

View File

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

View File

@@ -291,22 +291,6 @@ public abstract class PlayQueue implements Serializable {
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.
* <p>
@@ -545,7 +529,8 @@ public abstract class PlayQueue implements Serializable {
final PlayQueueItem stream = streams.get(i);
final PlayQueueItem otherStream = other.streams.get(i);
// Check is based on serviceId and URL
if (!stream.isSameItem(otherStream)) {
if (stream.getServiceId() != otherStream.getServiceId()
|| !stream.getUrl().equals(otherStream.getUrl())) {
return false;
}
}

View File

@@ -38,7 +38,7 @@ public class PlayQueueItem implements Serializable {
private long recoveryPosition;
private Throwable error;
public PlayQueueItem(@NonNull final StreamInfo info) {
PlayQueueItem(@NonNull final StreamInfo info) {
this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(),
info.getThumbnails(), info.getUploaderName(),
info.getUploaderUrl(), info.getStreamType());
@@ -71,22 +71,6 @@ public class PlayQueueItem implements Serializable {
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
public String getTitle() {
return title;

View File

@@ -16,9 +16,7 @@ public final class SinglePlayQueue extends PlayQueue {
public SinglePlayQueue(final StreamInfo 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) {
super(0, List.of(new PlayQueueItem(info)));
getItem().setRecoveryPosition(startPosition);

View File

@@ -57,10 +57,8 @@ import org.schabi.newpipe.local.subscription.SubscriptionFragment;
import org.schabi.newpipe.local.subscription.SubscriptionsImportFragment;
import org.schabi.newpipe.player.PlayQueueActivity;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.PlayerIntentType;
import org.schabi.newpipe.player.PlayerService;
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.PlayerHolder;
import org.schabi.newpipe.player.playqueue.PlayQueue;
@@ -69,7 +67,6 @@ import org.schabi.newpipe.settings.SettingsActivity;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.List;
import java.util.Optional;
public final class NavigationHelper {
public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag";
@@ -88,32 +85,54 @@ public final class NavigationHelper {
public static <T> Intent getPlayerIntent(@NonNull final Context context,
@NonNull final Class<T> targetClazz,
@Nullable final PlayQueue playQueue,
@NonNull final PlayerIntentType playerIntentType) {
final String cacheKey = Optional.ofNullable(playQueue)
.map(queue -> SerializedCache.getInstance().put(queue, PlayQueue.class))
.orElse(null);
return new Intent(context, targetClazz)
.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);
final boolean resumePlayback) {
final Intent intent = new Intent(context, targetClazz);
if (playQueue != null) {
final String cacheKey = SerializedCache.getInstance().put(playQueue, PlayQueue.class);
if (cacheKey != null) {
intent.putExtra(Player.PLAY_QUEUE_KEY, cacheKey);
}
}
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
public static Intent getPlayerTimestampIntent(@NonNull final Context context,
@NonNull final TimestampChangeData data) {
return new Intent(context, PlayerService.class)
.putExtra(Player.PLAYER_INTENT_TYPE, PlayerIntentType.TimestampChange)
.putExtra(Player.PLAYER_INTENT_DATA, data);
public static <T> Intent getPlayerIntent(@NonNull final Context context,
@NonNull final Class<T> targetClazz,
@Nullable final PlayQueue playQueue,
final boolean resumePlayback,
final boolean playWhenReady) {
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
public static <T> Intent getPlayerEnqueueNextIntent(@NonNull final Context context,
@NonNull final Class<T> targetClazz,
@Nullable final PlayQueue playQueue) {
return getPlayerIntent(context, targetClazz, playQueue, PlayerIntentType.EnqueueNext)
// see comment in `getPlayerEnqueueIntent` as to why `resumePlayback` is false
.putExtra(Player.RESUME_PLAYBACK, false);
// see comment in `getPlayerEnqueueIntent` as to why `resumePlayback` is false
return getPlayerIntent(context, targetClazz, playQueue, false)
.putExtra(Player.ENQUEUE_NEXT, true);
}
/* PLAY */
@@ -147,10 +166,8 @@ public final class NavigationHelper {
Toast.makeText(context, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show();
final var intent = getPlayerIntent(context, PlayerService.class, queue,
PlayerIntentType.AllOthers)
.putExtra(Player.PLAYER_TYPE, PlayerType.POPUP)
.putExtra(Player.RESUME_PLAYBACK, resumePlayback);
final Intent intent = getPlayerIntent(context, PlayerService.class, queue, resumePlayback);
intent.putExtra(Player.PLAYER_TYPE, PlayerType.POPUP.valueForIntent());
ContextCompat.startForegroundService(context, intent);
}
@@ -160,10 +177,8 @@ public final class NavigationHelper {
Toast.makeText(context, R.string.background_player_playing_toast, Toast.LENGTH_SHORT)
.show();
final Intent intent = getPlayerIntent(context, PlayerService.class, queue,
PlayerIntentType.AllOthers)
.putExtra(Player.PLAYER_TYPE, PlayerType.AUDIO)
.putExtra(Player.RESUME_PLAYBACK, resumePlayback);
final Intent intent = getPlayerIntent(context, PlayerService.class, queue, resumePlayback);
intent.putExtra(Player.PLAYER_TYPE, PlayerType.AUDIO.valueForIntent());
ContextCompat.startForegroundService(context, intent);
}
@@ -176,18 +191,9 @@ public final class NavigationHelper {
}
Toast.makeText(context, R.string.enqueued, Toast.LENGTH_SHORT).show();
final Intent intent = getPlayerEnqueueIntent(context, PlayerService.class, queue);
// 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);
intent.putExtra(Player.PLAYER_TYPE, playerType.valueForIntent());
ContextCompat.startForegroundService(context, intent);
}
@@ -209,8 +215,9 @@ public final class NavigationHelper {
playerType = PlayerType.AUDIO;
}
Toast.makeText(context, R.string.enqueued_next, Toast.LENGTH_SHORT).show();
final Intent intent = getPlayerEnqueueNextIntent(context, PlayerService.class, queue)
.putExtra(Player.PLAYER_TYPE, playerType);
final Intent intent = getPlayerEnqueueNextIntent(context, PlayerService.class, queue);
intent.putExtra(Player.PLAYER_TYPE, playerType.valueForIntent());
ContextCompat.startForegroundService(context, intent);
}

View File

@@ -5,10 +5,9 @@ import static org.schabi.newpipe.MainActivity.DEBUG;
import android.content.ActivityNotFoundException;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Build;
@@ -23,6 +22,7 @@ import androidx.core.content.FileProvider;
import org.schabi.newpipe.BuildConfig;
import org.schabi.newpipe.R;
import org.schabi.newpipe.RouterActivity;
import org.schabi.newpipe.extractor.Image;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
@@ -62,8 +62,9 @@ public final class ShareUtils {
}
/**
* Open the url with the system default browser. If no browser is set as default, falls back to
* {@link #openAppChooser(Context, Intent, boolean)}.
* Open the url with the system default browser. If no browser is installed, falls back to
* {@link #openAppChooser(Context, Intent, boolean)} (for displaying that no apps are available
* to handle the action, or possible OEM-related edge cases).
* <p>
* This function selects the package to open based on which apps respond to the {@code http://}
* schema alone, which should exclude special non-browser apps that are can handle the url (e.g.
@@ -77,44 +78,26 @@ public final class ShareUtils {
* @param url the url to browse
**/
public static void openUrlInBrowser(@NonNull final Context context, final String url) {
// Resolve using a generic http://, so we are sure to get a browser and not e.g. the yt app.
// Target a generic http://, so we are sure to get a browser and not e.g. the yt app.
// Note that this requires the `http` schema to be added to `<queries>` in the manifest.
final ResolveInfo defaultBrowserInfo;
final Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("http://"));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
defaultBrowserInfo = context.getPackageManager().resolveActivity(browserIntent,
PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY));
} else {
defaultBrowserInfo = context.getPackageManager().resolveActivity(browserIntent,
PackageManager.MATCH_DEFAULT_ONLY);
}
final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url))
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
if (defaultBrowserInfo == null) {
// No app installed to open a web URL, but it may be handled by other apps so try
// opening a system chooser for the link in this case (it could be bypassed by the
// system if there is only one app which can open the link or a default app associated
// with the link domain on Android 12 and higher)
// See https://stackoverflow.com/a/58801285 and `setSelector` documentation
intent.setSelector(browserIntent);
try {
context.startActivity(intent);
} catch (final ActivityNotFoundException e) {
// No browser is available. This should, in the end, yield a nice AOSP error message
// indicating that no app is available to handle this action.
//
// Note: there are some situations where modified OEM ROMs have apps that appear
// to be browsers but are actually app choosers. If starting the Activity fails
// related to this, opening the system app chooser is still the correct behavior.
intent.setSelector(null);
openAppChooser(context, intent, true);
return;
}
final String defaultBrowserPackage = defaultBrowserInfo.activityInfo.packageName;
if (defaultBrowserPackage.equals("android")) {
// No browser set as default (doesn't work on some devices)
openAppChooser(context, intent, true);
} else {
try {
intent.setPackage(defaultBrowserPackage);
context.startActivity(intent);
} catch (final ActivityNotFoundException e) {
// Not a browser but an app chooser because of OEMs changes
intent.setPackage(null);
openAppChooser(context, intent, true);
}
}
}
@@ -190,6 +173,18 @@ public final class ShareUtils {
chooserIntent.putExtra(Intent.EXTRA_TITLE, context.getString(R.string.open_with));
}
// Avoid opening in NewPipe
// (Implementation note: if the URL is one for which NewPipe itself
// is set as handler on Android >= 12, we actually remove the only eligible app
// for this link, and browsers will not be offered to the user. For that, use
// `openUrlInBrowser`.)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
chooserIntent.putExtra(
Intent.EXTRA_EXCLUDE_COMPONENTS,
new ComponentName[]{new ComponentName(context, RouterActivity.class)}
);
}
// Migrate any clip data and flags from the original intent.
final int permFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION

View File

@@ -1,27 +1,63 @@
package org.schabi.newpipe.util.text;
import android.content.Context;
import android.content.Intent;
import androidx.core.content.ContextCompat;
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.StreamingService;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.exceptions.ParsingException;
import org.schabi.newpipe.extractor.linkhandler.LinkHandlerFactory;
import org.schabi.newpipe.player.TimestampChangeData;
import org.schabi.newpipe.extractor.stream.StreamInfo;
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 java.util.regex.Matcher;
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 {
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 HASHTAG_TIMESTAMP_PATTERN =
Pattern.compile("(.*)#timestamp=(\\d+)");
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.
* <p>
@@ -30,13 +66,36 @@ public final class InternalUrlsHandler {
* 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.
*
* @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 handleUrlDescriptionTimestamp(final Context context,
public static boolean handleUrlDescriptionTimestamp(@NonNull final CompositeDisposable
disposables,
final Context context,
@NonNull final String url) {
final Matcher matcher = AMPERSAND_TIMESTAMP_PATTERN.matcher(url);
return handleUrl(context, url, AMPERSAND_TIMESTAMP_PATTERN, disposables);
}
/**
* 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()) {
return false;
}
@@ -61,7 +120,7 @@ public final class InternalUrlsHandler {
}
if (linkType == StreamingService.LinkType.STREAM && seconds != -1) {
return playOnPopup(context, matchedUrl, service, seconds);
return playOnPopup(context, matchedUrl, service, seconds, disposables);
} else {
NavigationHelper.openRouterActivity(context, matchedUrl);
return true;
@@ -75,12 +134,15 @@ public final class InternalUrlsHandler {
* @param url the URL of the content
* @param service the service of the content
* @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
*/
public static boolean playOnPopup(final Context context,
final String url,
@NonNull final StreamingService service,
final int seconds) {
final int seconds,
@NonNull final CompositeDisposable disposables) {
final LinkHandlerFactory factory = service.getStreamLHFactory();
final String cleanUrl;
@@ -90,14 +152,19 @@ public final class InternalUrlsHandler {
return false;
}
final Intent intent = NavigationHelper.getPlayerTimestampIntent(context,
new TimestampChangeData(
service.getServiceId(),
cleanUrl,
seconds
));
ContextCompat.startForegroundService(context, intent);
final Single<StreamInfo> single =
ExtractorHelper.getStreamInfo(service.getServiceId(), cleanUrl, false);
disposables.add(single.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(info -> {
final PlayQueue playQueue = new SinglePlayQueue(info, seconds * 1000L);
NavigationHelper.playOnPopupPlayer(context, playQueue, false);
}, 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.
ErrorUtil.showSnackbar(context,
new ErrorInfo(throwable, UserAction.PLAY_ON_POPUP, url, null, url));
}));
return true;
}
}

View File

@@ -192,7 +192,7 @@ public final class TextLinkifier {
* <p>
* 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
* with {@link ShareUtils#openUrlInBrowser(Context, String)}.
* with {@link ShareUtils#openUrlInBrowser(Context, String, boolean)}.
* </p>
*
* <p>
@@ -240,7 +240,7 @@ public final class TextLinkifier {
for (final URLSpan span : urls) {
final String url = span.getURL();
final LongPressClickableSpan longPressClickableSpan =
new UrlLongPressClickableSpan(context, url);
new UrlLongPressClickableSpan(context, disposables, url);
textBlockLinked.setSpan(longPressClickableSpan,
textBlockLinked.getSpanStart(span),

View File

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

View File

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

View File

@@ -661,8 +661,7 @@ public class DownloadMission extends Mission {
* @return {@code true}, if storage is invalid and cannot be used
*/
public boolean hasInvalidStorage() {
// Don't consider ERROR_PROGRESS_LOST as invalid storage - it can be recovered
return storage == null || !storage.existsAsFile();
return errCode == ERROR_PROGRESS_LOST || storage == null || !storage.existsAsFile();
}
/**

View File

@@ -24,8 +24,6 @@ import org.schabi.newpipe.streams.io.StoredFileHelper;
import us.shandian.giga.util.Utility;
import static org.schabi.newpipe.BuildConfig.DEBUG;
import static us.shandian.giga.get.DownloadMission.ERROR_NOTHING;
import static us.shandian.giga.get.DownloadMission.ERROR_PROGRESS_LOST;
public class DownloadManager {
private static final String TAG = DownloadManager.class.getSimpleName();
@@ -151,31 +149,12 @@ public class DownloadManager {
if (sub.getName().equals(".tmp")) continue;
DownloadMission mis = Utility.readFromFile(sub);
if (mis == null) {
if (mis == null || mis.isFinished() || mis.hasInvalidStorage()) {
//noinspection ResultOfMethodCallIgnored
sub.delete();
continue;
}
// DON'T delete missions that are truly finished - let them be moved to finished list
if (mis.isFinished()) {
// Move to finished missions instead of deleting
setFinished(mis);
//noinspection ResultOfMethodCallIgnored
sub.delete();
continue;
}
// DON'T delete missions with storage issues - try to recover them
if (mis.hasInvalidStorage() && mis.errCode != ERROR_PROGRESS_LOST) {
// Only delete if it's truly unrecoverable (not just progress lost)
if (mis.storage == null) {
//noinspection ResultOfMethodCallIgnored
sub.delete();
continue;
}
}
mis.threads = new Thread[0];
boolean exists;
@@ -184,13 +163,16 @@ public class DownloadManager {
exists = !mis.storage.isInvalid() && mis.storage.existsAsFile();
} catch (Exception ex) {
Log.e(TAG, "Failed to load the file source of " + mis.storage.toString(), ex);
// Don't invalidate storage immediately - try to recover first
mis.storage.invalidate();
exists = false;
}
if (mis.isPsRunning()) {
if (mis.psAlgorithm.worksOnSameFile) {
// Incomplete post-processing results in a corrupted download file
// because the selected algorithm works on the same file to save space.
// the file will be deleted if the storage API
// is Java IO (avoid showing the "Save as..." dialog)
if (exists && mis.storage.isDirect() && !mis.storage.delete())
Log.w(TAG, "Unable to delete incomplete download file: " + sub.getPath());
}
@@ -199,11 +181,10 @@ public class DownloadManager {
mis.errCode = DownloadMission.ERROR_POSTPROCESSING_STOPPED;
} else if (!exists) {
tryRecover(mis);
// Keep the mission even if recovery fails - don't reset to ERROR_PROGRESS_LOST
// This allows user to see the failed download and potentially retry
if (mis.isInitialized() && mis.errCode == ERROR_NOTHING) {
mis.resetState(true, true, ERROR_PROGRESS_LOST);
}
// the progress is lost, reset mission state
if (mis.isInitialized())
mis.resetState(true, true, DownloadMission.ERROR_PROGRESS_LOST);
}
if (mis.psAlgorithm != null) {
@@ -467,7 +448,7 @@ public class DownloadManager {
continue;
resumeMission(mission);
if (mission.errCode != ERROR_NOTHING) continue;
if (mission.errCode != DownloadMission.ERROR_NOTHING) continue;
if (mPrefQueueLimit) return true;
flag = true;
@@ -531,15 +512,6 @@ public class DownloadManager {
}
}
public boolean canRecoverMission(DownloadMission mission) {
if (mission == null) return false;
// Can recover missions with progress lost or storage issues
return mission.errCode == ERROR_PROGRESS_LOST ||
mission.storage == null ||
!mission.storage.existsAsFile();
}
public MissionState checkForExistingMission(StoredFileHelper storage) {
synchronized (this) {
DownloadMission pending = getPendingMission(storage);
@@ -612,13 +584,8 @@ public class DownloadManager {
ArrayList<Mission> finished = new ArrayList<>(mMissionsFinished);
List<Mission> remove = new ArrayList<>(hidden);
// Don't hide recoverable missions
remove.removeIf(mission -> {
if (mission instanceof DownloadMission dm && canRecoverMission(dm)) {
return false; // Don't remove recoverable missions
}
return pending.remove(mission) || finished.remove(mission);
});
// hide missions (if required)
remove.removeIf(mission -> pending.remove(mission) || finished.remove(mission));
int fakeTotal = pending.size();
if (fakeTotal > 0) fakeTotal++;

View File

@@ -7,7 +7,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.android.tools.build:gradle:8.7.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong

View File

@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
distributionSha256Sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists