Merge pull request #10712 from Stypox/notification-actions-api-33-2

[Android 13+] Restore support of custom notification actions
This commit is contained in:
Stypox 2023-12-30 21:55:44 +01:00 committed by GitHub
commit 1d8850d1b2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 683 additions and 373 deletions

View File

@ -1,10 +1,12 @@
package org.schabi.newpipe.player.mediasession;
import static org.schabi.newpipe.MainActivity.DEBUG;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_RECREATE_NOTIFICATION;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.os.Build;
import android.support.v4.media.MediaMetadataCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.util.Log;
@ -14,15 +16,23 @@ import androidx.annotation.Nullable;
import androidx.media.session.MediaButtonReceiver;
import com.google.android.exoplayer2.ForwardingPlayer;
import com.google.android.exoplayer2.Player.RepeatMode;
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.notification.NotificationActionData;
import org.schabi.newpipe.player.notification.NotificationConstants;
import org.schabi.newpipe.player.ui.PlayerUi;
import org.schabi.newpipe.player.ui.VideoPlayerUi;
import org.schabi.newpipe.util.StreamTypeUtil;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class MediaSessionPlayerUi extends PlayerUi
implements SharedPreferences.OnSharedPreferenceChangeListener {
@ -34,6 +44,10 @@ public class MediaSessionPlayerUi extends PlayerUi
private final String ignoreHardwareMediaButtonsKey;
private boolean shouldIgnoreHardwareMediaButtons = false;
// used to check whether any notification action changed, before sending costly updates
private List<NotificationActionData> prevNotificationActions = List.of();
public MediaSessionPlayerUi(@NonNull final Player player) {
super(player);
ignoreHardwareMediaButtonsKey =
@ -63,6 +77,10 @@ public class MediaSessionPlayerUi extends PlayerUi
sessionConnector.setMetadataDeduplicationEnabled(true);
sessionConnector.setMediaMetadataProvider(exoPlayer -> buildMediaMetadata());
// force updating media session actions by resetting the previous ones
prevNotificationActions = List.of();
updateMediaSessionActions();
}
@Override
@ -80,6 +98,7 @@ public class MediaSessionPlayerUi extends PlayerUi
mediaSession.release();
mediaSession = null;
}
prevNotificationActions = List.of();
}
@Override
@ -163,4 +182,109 @@ public class MediaSessionPlayerUi extends PlayerUi
return builder.build();
}
private void updateMediaSessionActions() {
// On Android 13+ (or Android T or API 33+) the actions in the player notification can't be
// controlled directly anymore, but are instead derived from custom media session actions.
// However the system allows customizing only two of these actions, since the other three
// are fixed to play-pause-buffering, previous, next.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
// Although setting media session actions on older android versions doesn't seem to
// cause any trouble, it also doesn't seem to do anything, so we don't do anything to
// save battery. Check out NotificationUtil.updateActions() to see what happens on
// older android versions.
return;
}
// only use the fourth and fifth actions (the settings page also shows only the last 2 on
// Android 13+)
final List<NotificationActionData> newNotificationActions = IntStream.of(3, 4)
.map(i -> player.getPrefs().getInt(
player.getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]),
NotificationConstants.SLOT_DEFAULTS[i]))
.mapToObj(action -> NotificationActionData
.fromNotificationActionEnum(player, action))
.filter(Objects::nonNull)
.collect(Collectors.toList());
// avoid costly notification actions update, if nothing changed from last time
if (!newNotificationActions.equals(prevNotificationActions)) {
prevNotificationActions = newNotificationActions;
sessionConnector.setCustomActionProviders(
newNotificationActions.stream()
.map(data -> new SessionConnectorActionProvider(data, context))
.toArray(SessionConnectorActionProvider[]::new));
}
}
@Override
public void onBlocked() {
super.onBlocked();
updateMediaSessionActions();
}
@Override
public void onPlaying() {
super.onPlaying();
updateMediaSessionActions();
}
@Override
public void onBuffering() {
super.onBuffering();
updateMediaSessionActions();
}
@Override
public void onPaused() {
super.onPaused();
updateMediaSessionActions();
}
@Override
public void onPausedSeek() {
super.onPausedSeek();
updateMediaSessionActions();
}
@Override
public void onCompleted() {
super.onCompleted();
updateMediaSessionActions();
}
@Override
public void onRepeatModeChanged(@RepeatMode final int repeatMode) {
super.onRepeatModeChanged(repeatMode);
updateMediaSessionActions();
}
@Override
public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) {
super.onShuffleModeEnabledChanged(shuffleModeEnabled);
updateMediaSessionActions();
}
@Override
public void onBroadcastReceived(final Intent intent) {
super.onBroadcastReceived(intent);
if (ACTION_RECREATE_NOTIFICATION.equals(intent.getAction())) {
// the notification actions changed
updateMediaSessionActions();
}
}
@Override
public void onMetadataChanged(@NonNull final StreamInfo info) {
super.onMetadataChanged(info);
updateMediaSessionActions();
}
@Override
public void onPlayQueueEdited() {
super.onPlayQueueEdited();
updateMediaSessionActions();
}
}

View File

@ -0,0 +1,47 @@
package org.schabi.newpipe.player.mediasession;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.support.v4.media.session.PlaybackStateCompat;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
import org.schabi.newpipe.player.notification.NotificationActionData;
import java.lang.ref.WeakReference;
public class SessionConnectorActionProvider implements MediaSessionConnector.CustomActionProvider {
private final NotificationActionData data;
@NonNull
private final WeakReference<Context> context;
public SessionConnectorActionProvider(final NotificationActionData notificationActionData,
@NonNull final Context context) {
this.data = notificationActionData;
this.context = new WeakReference<>(context);
}
@Override
public void onCustomAction(@NonNull final Player player,
@NonNull final String action,
@Nullable final Bundle extras) {
final Context actualContext = context.get();
if (actualContext != null) {
actualContext.sendBroadcast(new Intent(action));
}
}
@Nullable
@Override
public PlaybackStateCompat.CustomAction getCustomAction(@NonNull final Player player) {
return new PlaybackStateCompat.CustomAction.Builder(
data.action(), data.name(), data.icon()
).build();
}
}

View File

@ -0,0 +1,187 @@
package org.schabi.newpipe.player.notification;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_FORWARD;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_REWIND;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_NEXT;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PREVIOUS;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_REPEAT;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_SHUFFLE;
import android.annotation.SuppressLint;
import android.content.Context;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.schabi.newpipe.R;
import org.schabi.newpipe.player.Player;
import java.util.Objects;
public final class NotificationActionData {
@NonNull
private final String action;
@NonNull
private final String name;
@DrawableRes
private final int icon;
public NotificationActionData(@NonNull final String action, @NonNull final String name,
@DrawableRes final int icon) {
this.action = action;
this.name = name;
this.icon = icon;
}
@NonNull
public String action() {
return action;
}
@NonNull
public String name() {
return name;
}
@DrawableRes
public int icon() {
return icon;
}
@SuppressLint("PrivateResource") // we currently use Exoplayer's internal strings and icons
@Nullable
public static NotificationActionData fromNotificationActionEnum(
@NonNull final Player player,
@NotificationConstants.Action final int selectedAction
) {
final int baseActionIcon = NotificationConstants.ACTION_ICONS[selectedAction];
final Context ctx = player.getContext();
switch (selectedAction) {
case NotificationConstants.PREVIOUS:
return new NotificationActionData(ACTION_PLAY_PREVIOUS,
ctx.getString(R.string.exo_controls_previous_description), baseActionIcon);
case NotificationConstants.NEXT:
return new NotificationActionData(ACTION_PLAY_NEXT,
ctx.getString(R.string.exo_controls_next_description), baseActionIcon);
case NotificationConstants.REWIND:
return new NotificationActionData(ACTION_FAST_REWIND,
ctx.getString(R.string.exo_controls_rewind_description), baseActionIcon);
case NotificationConstants.FORWARD:
return new NotificationActionData(ACTION_FAST_FORWARD,
ctx.getString(R.string.exo_controls_fastforward_description),
baseActionIcon);
case NotificationConstants.SMART_REWIND_PREVIOUS:
if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) {
return new NotificationActionData(ACTION_PLAY_PREVIOUS,
ctx.getString(R.string.exo_controls_previous_description),
R.drawable.exo_notification_previous);
} else {
return new NotificationActionData(ACTION_FAST_REWIND,
ctx.getString(R.string.exo_controls_rewind_description),
R.drawable.exo_controls_rewind);
}
case NotificationConstants.SMART_FORWARD_NEXT:
if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) {
return new NotificationActionData(ACTION_PLAY_NEXT,
ctx.getString(R.string.exo_controls_next_description),
R.drawable.exo_notification_next);
} else {
return new NotificationActionData(ACTION_FAST_FORWARD,
ctx.getString(R.string.exo_controls_fastforward_description),
R.drawable.exo_controls_fastforward);
}
case NotificationConstants.PLAY_PAUSE_BUFFERING:
if (player.getCurrentState() == Player.STATE_PREFLIGHT
|| player.getCurrentState() == Player.STATE_BLOCKED
|| player.getCurrentState() == Player.STATE_BUFFERING) {
return new NotificationActionData(ACTION_PLAY_PAUSE,
ctx.getString(R.string.notification_action_buffering),
R.drawable.ic_hourglass_top);
}
// fallthrough
case NotificationConstants.PLAY_PAUSE:
if (player.getCurrentState() == Player.STATE_COMPLETED) {
return new NotificationActionData(ACTION_PLAY_PAUSE,
ctx.getString(R.string.exo_controls_pause_description),
R.drawable.ic_replay);
} else if (player.isPlaying()
|| player.getCurrentState() == Player.STATE_PREFLIGHT
|| player.getCurrentState() == Player.STATE_BLOCKED
|| player.getCurrentState() == Player.STATE_BUFFERING) {
return new NotificationActionData(ACTION_PLAY_PAUSE,
ctx.getString(R.string.exo_controls_pause_description),
R.drawable.exo_notification_pause);
} else {
return new NotificationActionData(ACTION_PLAY_PAUSE,
ctx.getString(R.string.exo_controls_play_description),
R.drawable.exo_notification_play);
}
case NotificationConstants.REPEAT:
if (player.getRepeatMode() == REPEAT_MODE_ALL) {
return new NotificationActionData(ACTION_REPEAT,
ctx.getString(R.string.exo_controls_repeat_all_description),
R.drawable.exo_media_action_repeat_all);
} else if (player.getRepeatMode() == REPEAT_MODE_ONE) {
return new NotificationActionData(ACTION_REPEAT,
ctx.getString(R.string.exo_controls_repeat_one_description),
R.drawable.exo_media_action_repeat_one);
} else /* player.getRepeatMode() == REPEAT_MODE_OFF */ {
return new NotificationActionData(ACTION_REPEAT,
ctx.getString(R.string.exo_controls_repeat_off_description),
R.drawable.exo_media_action_repeat_off);
}
case NotificationConstants.SHUFFLE:
if (player.getPlayQueue() != null && player.getPlayQueue().isShuffled()) {
return new NotificationActionData(ACTION_SHUFFLE,
ctx.getString(R.string.exo_controls_shuffle_on_description),
R.drawable.exo_controls_shuffle_on);
} else {
return new NotificationActionData(ACTION_SHUFFLE,
ctx.getString(R.string.exo_controls_shuffle_off_description),
R.drawable.exo_controls_shuffle_off);
}
case NotificationConstants.CLOSE:
return new NotificationActionData(ACTION_CLOSE, ctx.getString(R.string.close),
R.drawable.ic_close);
case NotificationConstants.NOTHING:
default:
// do nothing
return null;
}
}
@Override
public boolean equals(@Nullable final Object obj) {
return (obj instanceof NotificationActionData other)
&& this.action.equals(other.action)
&& this.name.equals(other.name)
&& this.icon == other.icon;
}
@Override
public int hashCode() {
return Objects.hash(action, name, icon);
}
}

View File

@ -13,7 +13,7 @@ import org.schabi.newpipe.util.Localization;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.SortedSet;
import java.util.TreeSet;
@ -65,10 +65,16 @@ public final class NotificationConstants {
public static final int CLOSE = 11;
@Retention(RetentionPolicy.SOURCE)
@IntDef({NOTHING, PREVIOUS, NEXT, REWIND, FORWARD, SMART_REWIND_PREVIOUS, SMART_FORWARD_NEXT,
PLAY_PAUSE, PLAY_PAUSE_BUFFERING, REPEAT, SHUFFLE, CLOSE})
@IntDef({NOTHING, PREVIOUS, NEXT, REWIND, FORWARD,
SMART_REWIND_PREVIOUS, SMART_FORWARD_NEXT, PLAY_PAUSE, PLAY_PAUSE_BUFFERING, REPEAT,
SHUFFLE, CLOSE})
public @interface Action { }
@Action
public static final int[] ALL_ACTIONS = {NOTHING, PREVIOUS, NEXT, REWIND, FORWARD,
SMART_REWIND_PREVIOUS, SMART_FORWARD_NEXT, PLAY_PAUSE, PLAY_PAUSE_BUFFERING, REPEAT,
SHUFFLE, CLOSE};
@DrawableRes
public static final int[] ACTION_ICONS = {
0,
@ -95,16 +101,6 @@ public final class NotificationConstants {
CLOSE,
};
@Action
public static final int[][] SLOT_ALLOWED_ACTIONS = {
new int[] {PREVIOUS, REWIND, SMART_REWIND_PREVIOUS},
new int[] {REWIND, PLAY_PAUSE, PLAY_PAUSE_BUFFERING},
new int[] {NEXT, FORWARD, SMART_FORWARD_NEXT, PLAY_PAUSE, PLAY_PAUSE_BUFFERING},
new int[] {NOTHING, PREVIOUS, NEXT, REWIND, FORWARD, SMART_REWIND_PREVIOUS,
SMART_FORWARD_NEXT, REPEAT, SHUFFLE, CLOSE},
new int[] {NOTHING, NEXT, FORWARD, SMART_FORWARD_NEXT, REPEAT, SHUFFLE, CLOSE},
};
public static final int[] SLOT_PREF_KEYS = {
R.string.notification_slot_0_key,
R.string.notification_slot_1_key,
@ -165,14 +161,11 @@ public final class NotificationConstants {
/**
* @param context the context to use
* @param sharedPreferences the shared preferences to query values from
* @param slotCount remove indices >= than this value (set to {@code 5} to do nothing, or make
* it lower if there are slots with empty actions)
* @return a sorted list of the indices of the slots to use as compact slots
*/
public static List<Integer> getCompactSlotsFromPreferences(
public static Collection<Integer> getCompactSlotsFromPreferences(
@NonNull final Context context,
final SharedPreferences sharedPreferences,
final int slotCount) {
final SharedPreferences sharedPreferences) {
final SortedSet<Integer> compactSlots = new TreeSet<>();
for (int i = 0; i < 3; i++) {
final int compactSlot = sharedPreferences.getInt(
@ -180,14 +173,14 @@ public final class NotificationConstants {
if (compactSlot == Integer.MAX_VALUE) {
// settings not yet populated, return default values
return new ArrayList<>(SLOT_COMPACT_DEFAULTS);
return SLOT_COMPACT_DEFAULTS;
}
// a negative value (-1) is set when the user does not want a particular compact slot
if (compactSlot >= 0 && compactSlot < slotCount) {
if (compactSlot >= 0) {
// compact slot is < 0 if there are less than 3 checked checkboxes
compactSlots.add(compactSlot);
}
}
return new ArrayList<>(compactSlots);
return compactSlots;
}
}

View File

@ -1,16 +1,19 @@
package org.schabi.newpipe.player.notification;
import static android.app.PendingIntent.FLAG_UPDATE_CURRENT;
import static androidx.media.app.NotificationCompat.MediaStyle;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE;
import android.annotation.SuppressLint;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.graphics.Bitmap;
import android.os.Build;
import android.util.Log;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.app.PendingIntentCompat;
@ -23,23 +26,12 @@ import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
import org.schabi.newpipe.util.NavigationHelper;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import static android.app.PendingIntent.FLAG_UPDATE_CURRENT;
import static androidx.media.app.NotificationCompat.MediaStyle;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_FORWARD;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_REWIND;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_NEXT;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PREVIOUS;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_REPEAT;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_SHUFFLE;
/**
* This is a utility class for player notifications.
*/
@ -100,29 +92,21 @@ public final class NotificationUtil {
final NotificationCompat.Builder builder =
new NotificationCompat.Builder(player.getContext(),
player.getContext().getString(R.string.notification_channel_id));
final MediaStyle mediaStyle = new MediaStyle();
initializeNotificationSlots();
// count the number of real slots, to make sure compact slots indices are not out of bound
int nonNothingSlotCount = 5;
if (notificationSlots[3] == NotificationConstants.NOTHING) {
--nonNothingSlotCount;
// setup media style (compact notification slots and media session)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
// notification actions are ignored on Android 13+, and are replaced by code in
// MediaSessionPlayerUi
final int[] compactSlots = initializeNotificationSlots();
mediaStyle.setShowActionsInCompactView(compactSlots);
}
if (notificationSlots[4] == NotificationConstants.NOTHING) {
--nonNothingSlotCount;
}
// build the compact slot indices array (need code to convert from Integer... because Java)
final List<Integer> compactSlotList = NotificationConstants.getCompactSlotsFromPreferences(
player.getContext(), player.getPrefs(), nonNothingSlotCount);
final int[] compactSlots = compactSlotList.stream().mapToInt(Integer::intValue).toArray();
final MediaStyle mediaStyle = new MediaStyle().setShowActionsInCompactView(compactSlots);
player.UIs()
.get(MediaSessionPlayerUi.class)
.flatMap(MediaSessionPlayerUi::getSessionToken)
.ifPresent(mediaStyle::setMediaSession);
// setup notification builder
builder.setStyle(mediaStyle)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
@ -157,7 +141,11 @@ public final class NotificationUtil {
notificationBuilder.setContentText(player.getUploaderName());
notificationBuilder.setTicker(player.getVideoTitle());
updateActions(notificationBuilder);
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
// notification actions are ignored on Android 13+, and are replaced by code in
// MediaSessionPlayerUi
updateActions(notificationBuilder);
}
}
@ -209,12 +197,35 @@ public final class NotificationUtil {
// ACTIONS
/////////////////////////////////////////////////////
private void initializeNotificationSlots() {
/**
* The compact slots array from settings contains indices from 0 to 4, each referring to one of
* the five actions configurable by the user. However, if the user sets an action to "Nothing",
* then all of the actions coming after will have a "settings index" different than the index
* of the corresponding action when sent to the system.
*
* @return the indices of compact slots referred to the list of non-nothing actions that will be
* sent to the system
*/
private int[] initializeNotificationSlots() {
final Collection<Integer> settingsCompactSlots = NotificationConstants
.getCompactSlotsFromPreferences(player.getContext(), player.getPrefs());
final List<Integer> adjustedCompactSlots = new ArrayList<>();
int nonNothingIndex = 0;
for (int i = 0; i < 5; ++i) {
notificationSlots[i] = player.getPrefs().getInt(
player.getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]),
NotificationConstants.SLOT_DEFAULTS[i]);
if (notificationSlots[i] != NotificationConstants.NOTHING) {
if (settingsCompactSlots.contains(i)) {
adjustedCompactSlots.add(nonNothingIndex);
}
nonNothingIndex += 1;
}
}
return adjustedCompactSlots.stream().mapToInt(Integer::intValue).toArray();
}
@SuppressLint("RestrictedApi")
@ -227,115 +238,15 @@ public final class NotificationUtil {
private void addAction(final NotificationCompat.Builder builder,
@NotificationConstants.Action final int slot) {
final NotificationCompat.Action action = getAction(slot);
if (action != null) {
builder.addAction(action);
@Nullable final NotificationActionData data =
NotificationActionData.fromNotificationActionEnum(player, slot);
if (data == null) {
return;
}
}
@Nullable
private NotificationCompat.Action getAction(
@NotificationConstants.Action final int selectedAction) {
final int baseActionIcon = NotificationConstants.ACTION_ICONS[selectedAction];
switch (selectedAction) {
case NotificationConstants.PREVIOUS:
return getAction(baseActionIcon,
R.string.exo_controls_previous_description, ACTION_PLAY_PREVIOUS);
case NotificationConstants.NEXT:
return getAction(baseActionIcon,
R.string.exo_controls_next_description, ACTION_PLAY_NEXT);
case NotificationConstants.REWIND:
return getAction(baseActionIcon,
R.string.exo_controls_rewind_description, ACTION_FAST_REWIND);
case NotificationConstants.FORWARD:
return getAction(baseActionIcon,
R.string.exo_controls_fastforward_description, ACTION_FAST_FORWARD);
case NotificationConstants.SMART_REWIND_PREVIOUS:
if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) {
return getAction(R.drawable.exo_notification_previous,
R.string.exo_controls_previous_description, ACTION_PLAY_PREVIOUS);
} else {
return getAction(R.drawable.exo_controls_rewind,
R.string.exo_controls_rewind_description, ACTION_FAST_REWIND);
}
case NotificationConstants.SMART_FORWARD_NEXT:
if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) {
return getAction(R.drawable.exo_notification_next,
R.string.exo_controls_next_description, ACTION_PLAY_NEXT);
} else {
return getAction(R.drawable.exo_controls_fastforward,
R.string.exo_controls_fastforward_description, ACTION_FAST_FORWARD);
}
case NotificationConstants.PLAY_PAUSE_BUFFERING:
if (player.getCurrentState() == Player.STATE_PREFLIGHT
|| player.getCurrentState() == Player.STATE_BLOCKED
|| player.getCurrentState() == Player.STATE_BUFFERING) {
// null intent -> show hourglass icon that does nothing when clicked
return new NotificationCompat.Action(R.drawable.ic_hourglass_top,
player.getContext().getString(R.string.notification_action_buffering),
null);
}
// fallthrough
case NotificationConstants.PLAY_PAUSE:
if (player.getCurrentState() == Player.STATE_COMPLETED) {
return getAction(R.drawable.ic_replay,
R.string.exo_controls_pause_description, ACTION_PLAY_PAUSE);
} else if (player.isPlaying()
|| player.getCurrentState() == Player.STATE_PREFLIGHT
|| player.getCurrentState() == Player.STATE_BLOCKED
|| player.getCurrentState() == Player.STATE_BUFFERING) {
return getAction(R.drawable.exo_notification_pause,
R.string.exo_controls_pause_description, ACTION_PLAY_PAUSE);
} else {
return getAction(R.drawable.exo_notification_play,
R.string.exo_controls_play_description, ACTION_PLAY_PAUSE);
}
case NotificationConstants.REPEAT:
if (player.getRepeatMode() == REPEAT_MODE_ALL) {
return getAction(R.drawable.exo_media_action_repeat_all,
R.string.exo_controls_repeat_all_description, ACTION_REPEAT);
} else if (player.getRepeatMode() == REPEAT_MODE_ONE) {
return getAction(R.drawable.exo_media_action_repeat_one,
R.string.exo_controls_repeat_one_description, ACTION_REPEAT);
} else /* player.getRepeatMode() == REPEAT_MODE_OFF */ {
return getAction(R.drawable.exo_media_action_repeat_off,
R.string.exo_controls_repeat_off_description, ACTION_REPEAT);
}
case NotificationConstants.SHUFFLE:
if (player.getPlayQueue() != null && player.getPlayQueue().isShuffled()) {
return getAction(R.drawable.exo_controls_shuffle_on,
R.string.exo_controls_shuffle_on_description, ACTION_SHUFFLE);
} else {
return getAction(R.drawable.exo_controls_shuffle_off,
R.string.exo_controls_shuffle_off_description, ACTION_SHUFFLE);
}
case NotificationConstants.CLOSE:
return getAction(R.drawable.ic_close,
R.string.close, ACTION_CLOSE);
case NotificationConstants.NOTHING:
default:
// do nothing
return null;
}
}
private NotificationCompat.Action getAction(@DrawableRes final int drawable,
@StringRes final int title,
final String intentAction) {
return new NotificationCompat.Action(drawable, player.getContext().getString(title),
PendingIntentCompat.getBroadcast(player.getContext(), NOTIFICATION_ID,
new Intent(intentAction), FLAG_UPDATE_CURRENT, false));
final PendingIntent intent = PendingIntentCompat.getBroadcast(player.getContext(),
NOTIFICATION_ID, new Intent(data.action()), FLAG_UPDATE_CURRENT, false);
builder.addAction(new NotificationCompat.Action(data.icon(), data.name(), intent));
}
private Intent getIntentForNotification() {

View File

@ -5,35 +5,22 @@ import static org.schabi.newpipe.player.notification.NotificationConstants.ACTIO
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.res.ColorStateList;
import android.os.Build;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.ImageView;
import android.widget.RadioButton;
import android.widget.RadioGroup;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.widget.TextViewCompat;
import androidx.preference.Preference;
import androidx.preference.PreferenceViewHolder;
import org.schabi.newpipe.App;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.ListRadioIconItemBinding;
import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding;
import org.schabi.newpipe.player.notification.NotificationConstants;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.views.FocusOverlayView;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;
@ -45,8 +32,9 @@ public class NotificationActionsPreference extends Preference {
}
@Nullable private NotificationSlot[] notificationSlots = null;
@Nullable private List<Integer> compactSlots = null;
private NotificationSlot[] notificationSlots;
private List<Integer> compactSlots;
////////////////////////////////////////////////////////////////////////////
// Lifecycle
@ -56,6 +44,11 @@ public class NotificationActionsPreference extends Preference {
public void onBindViewHolder(@NonNull final PreferenceViewHolder holder) {
super.onBindViewHolder(holder);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
((TextView) holder.itemView.findViewById(R.id.summary))
.setText(R.string.notification_actions_summary_android13);
}
holder.itemView.setClickable(false);
setupActions(holder.itemView);
}
@ -75,13 +68,29 @@ public class NotificationActionsPreference extends Preference {
////////////////////////////////////////////////////////////////////////////
private void setupActions(@NonNull final View view) {
compactSlots = NotificationConstants.getCompactSlotsFromPreferences(getContext(),
getSharedPreferences(), 5);
compactSlots = new ArrayList<>(NotificationConstants.getCompactSlotsFromPreferences(
getContext(), getSharedPreferences()));
notificationSlots = IntStream.range(0, 5)
.mapToObj(i -> new NotificationSlot(i, view))
.mapToObj(i -> new NotificationSlot(getContext(), getSharedPreferences(), i, view,
compactSlots.contains(i), this::onToggleCompactSlot))
.toArray(NotificationSlot[]::new);
}
private void onToggleCompactSlot(final int i, final CheckBox checkBox) {
if (checkBox.isChecked()) {
compactSlots.remove((Integer) i);
} else if (compactSlots.size() < 3) {
compactSlots.add(i);
} else {
Toast.makeText(getContext(),
R.string.notification_actions_at_most_three,
Toast.LENGTH_SHORT).show();
return;
}
checkBox.toggle();
}
////////////////////////////////////////////////////////////////////////////
// Saving
@ -99,143 +108,10 @@ public class NotificationActionsPreference extends Preference {
for (int i = 0; i < 5; i++) {
editor.putInt(getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]),
notificationSlots[i].selectedAction);
notificationSlots[i].getSelectedAction());
}
editor.apply();
}
}
////////////////////////////////////////////////////////////////////////////
// Notification action
////////////////////////////////////////////////////////////////////////////
private static final int[] SLOT_ITEMS = {
R.id.notificationAction0,
R.id.notificationAction1,
R.id.notificationAction2,
R.id.notificationAction3,
R.id.notificationAction4,
};
private static final int[] SLOT_TITLES = {
R.string.notification_action_0_title,
R.string.notification_action_1_title,
R.string.notification_action_2_title,
R.string.notification_action_3_title,
R.string.notification_action_4_title,
};
private class NotificationSlot {
final int i;
@NotificationConstants.Action int selectedAction;
ImageView icon;
TextView summary;
NotificationSlot(final int actionIndex, final View parentView) {
this.i = actionIndex;
final View view = parentView.findViewById(SLOT_ITEMS[i]);
setupSelectedAction(view);
setupTitle(view);
setupCheckbox(view);
}
void setupTitle(final View view) {
((TextView) view.findViewById(R.id.notificationActionTitle))
.setText(SLOT_TITLES[i]);
view.findViewById(R.id.notificationActionClickableArea).setOnClickListener(
v -> openActionChooserDialog());
}
void setupCheckbox(final View view) {
final CheckBox compactSlotCheckBox = view.findViewById(R.id.notificationActionCheckBox);
compactSlotCheckBox.setChecked(compactSlots.contains(i));
view.findViewById(R.id.notificationActionCheckBoxClickableArea).setOnClickListener(
v -> {
if (compactSlotCheckBox.isChecked()) {
compactSlots.remove((Integer) i);
} else if (compactSlots.size() < 3) {
compactSlots.add(i);
} else {
Toast.makeText(getContext(),
R.string.notification_actions_at_most_three,
Toast.LENGTH_SHORT).show();
return;
}
compactSlotCheckBox.toggle();
});
}
void setupSelectedAction(final View view) {
icon = view.findViewById(R.id.notificationActionIcon);
summary = view.findViewById(R.id.notificationActionSummary);
selectedAction = getSharedPreferences().getInt(
getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]),
NotificationConstants.SLOT_DEFAULTS[i]);
updateInfo();
}
void updateInfo() {
if (NotificationConstants.ACTION_ICONS[selectedAction] == 0) {
icon.setImageDrawable(null);
} else {
icon.setImageDrawable(AppCompatResources.getDrawable(getContext(),
NotificationConstants.ACTION_ICONS[selectedAction]));
}
summary.setText(NotificationConstants.getActionName(getContext(), selectedAction));
}
void openActionChooserDialog() {
final LayoutInflater inflater = LayoutInflater.from(getContext());
final SingleChoiceDialogViewBinding binding =
SingleChoiceDialogViewBinding.inflate(inflater);
final AlertDialog alertDialog = new AlertDialog.Builder(getContext())
.setTitle(SLOT_TITLES[i])
.setView(binding.getRoot())
.setCancelable(true)
.create();
final View.OnClickListener radioButtonsClickListener = v -> {
selectedAction = NotificationConstants.SLOT_ALLOWED_ACTIONS[i][v.getId()];
updateInfo();
alertDialog.dismiss();
};
for (int id = 0; id < NotificationConstants.SLOT_ALLOWED_ACTIONS[i].length; ++id) {
final int action = NotificationConstants.SLOT_ALLOWED_ACTIONS[i][id];
final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater)
.getRoot();
// if present set action icon with correct color
final int iconId = NotificationConstants.ACTION_ICONS[action];
if (iconId != 0) {
radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, iconId, 0);
final var color = ColorStateList.valueOf(ThemeHelper
.resolveColorFromAttr(getContext(), android.R.attr.textColorPrimary));
TextViewCompat.setCompoundDrawableTintList(radioButton, color);
}
radioButton.setText(NotificationConstants.getActionName(getContext(), action));
radioButton.setChecked(action == selectedAction);
radioButton.setId(id);
radioButton.setLayoutParams(new RadioGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
radioButton.setOnClickListener(radioButtonsClickListener);
binding.list.addView(radioButton);
}
alertDialog.show();
if (DeviceUtils.isTv(getContext())) {
FocusOverlayView.setupFocusObserver(alertDialog);
}
}
}
}

View File

@ -0,0 +1,172 @@
package org.schabi.newpipe.settings.custom;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.ColorStateList;
import android.os.Build;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.ImageView;
import android.widget.RadioButton;
import android.widget.RadioGroup;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.widget.TextViewCompat;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.ListRadioIconItemBinding;
import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding;
import org.schabi.newpipe.player.notification.NotificationConstants;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.views.FocusOverlayView;
import java.util.Objects;
import java.util.function.BiConsumer;
class NotificationSlot {
private static final int[] SLOT_ITEMS = {
R.id.notificationAction0,
R.id.notificationAction1,
R.id.notificationAction2,
R.id.notificationAction3,
R.id.notificationAction4,
};
private static final int[] SLOT_TITLES = {
R.string.notification_action_0_title,
R.string.notification_action_1_title,
R.string.notification_action_2_title,
R.string.notification_action_3_title,
R.string.notification_action_4_title,
};
private final int i;
private @NotificationConstants.Action int selectedAction;
private final Context context;
private final BiConsumer<Integer, CheckBox> onToggleCompactSlot;
private ImageView icon;
private TextView summary;
NotificationSlot(final Context context,
final SharedPreferences prefs,
final int actionIndex,
final View parentView,
final boolean isCompactSlotChecked,
final BiConsumer<Integer, CheckBox> onToggleCompactSlot) {
this.context = context;
this.i = actionIndex;
this.onToggleCompactSlot = onToggleCompactSlot;
selectedAction = Objects.requireNonNull(prefs).getInt(
context.getString(NotificationConstants.SLOT_PREF_KEYS[i]),
NotificationConstants.SLOT_DEFAULTS[i]);
final View view = parentView.findViewById(SLOT_ITEMS[i]);
// only show the last two notification slots on Android 13+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || i >= 3) {
setupSelectedAction(view);
setupTitle(view);
setupCheckbox(view, isCompactSlotChecked);
} else {
view.setVisibility(View.GONE);
}
}
void setupTitle(final View view) {
((TextView) view.findViewById(R.id.notificationActionTitle))
.setText(SLOT_TITLES[i]);
view.findViewById(R.id.notificationActionClickableArea).setOnClickListener(
v -> openActionChooserDialog());
}
void setupCheckbox(final View view, final boolean isCompactSlotChecked) {
final CheckBox compactSlotCheckBox = view.findViewById(R.id.notificationActionCheckBox);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// there are no compact slots to customize on Android 13+
compactSlotCheckBox.setVisibility(View.GONE);
view.findViewById(R.id.notificationActionCheckBoxClickableArea)
.setVisibility(View.GONE);
return;
}
compactSlotCheckBox.setChecked(isCompactSlotChecked);
view.findViewById(R.id.notificationActionCheckBoxClickableArea).setOnClickListener(
v -> onToggleCompactSlot.accept(i, compactSlotCheckBox));
}
void setupSelectedAction(final View view) {
icon = view.findViewById(R.id.notificationActionIcon);
summary = view.findViewById(R.id.notificationActionSummary);
updateInfo();
}
void updateInfo() {
if (NotificationConstants.ACTION_ICONS[selectedAction] == 0) {
icon.setImageDrawable(null);
} else {
icon.setImageDrawable(AppCompatResources.getDrawable(context,
NotificationConstants.ACTION_ICONS[selectedAction]));
}
summary.setText(NotificationConstants.getActionName(context, selectedAction));
}
void openActionChooserDialog() {
final LayoutInflater inflater = LayoutInflater.from(context);
final SingleChoiceDialogViewBinding binding =
SingleChoiceDialogViewBinding.inflate(inflater);
final AlertDialog alertDialog = new AlertDialog.Builder(context)
.setTitle(SLOT_TITLES[i])
.setView(binding.getRoot())
.setCancelable(true)
.create();
final View.OnClickListener radioButtonsClickListener = v -> {
selectedAction = NotificationConstants.ALL_ACTIONS[v.getId()];
updateInfo();
alertDialog.dismiss();
};
for (int id = 0; id < NotificationConstants.ALL_ACTIONS.length; ++id) {
final int action = NotificationConstants.ALL_ACTIONS[id];
final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater)
.getRoot();
// if present set action icon with correct color
final int iconId = NotificationConstants.ACTION_ICONS[action];
if (iconId != 0) {
radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, iconId, 0);
final var color = ColorStateList.valueOf(ThemeHelper
.resolveColorFromAttr(context, android.R.attr.textColorPrimary));
TextViewCompat.setCompoundDrawableTintList(radioButton, color);
}
radioButton.setText(NotificationConstants.getActionName(context, action));
radioButton.setChecked(action == selectedAction);
radioButton.setId(id);
radioButton.setLayoutParams(new RadioGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
radioButton.setOnClickListener(radioButtonsClickListener);
binding.list.addView(radioButton);
}
alertDialog.show();
if (DeviceUtils.isTv(context)) {
FocusOverlayView.setupFocusObserver(alertDialog);
}
}
@NotificationConstants.Action
public int getSelectedAction() {
return selectedAction;
}
}

View File

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
@ -8,64 +7,64 @@
android:paddingTop="16dp">
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/textView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:clickable="false"
android:focusable="false"
android:gravity="center"
android:text="@string/notification_actions_summary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
android:id="@+id/summary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:clickable="false"
android:focusable="false"
android:gravity="center"
android:text="@string/notification_actions_summary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<include
android:id="@+id/notificationAction0"
layout="@layout/settings_notification_action"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView" />
<include
android:id="@+id/notificationAction0"
layout="@layout/settings_notification_action"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/summary" />
<include
android:id="@+id/notificationAction1"
layout="@layout/settings_notification_action"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/notificationAction0" />
<include
android:id="@+id/notificationAction1"
layout="@layout/settings_notification_action"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/notificationAction0" />
<include
android:id="@+id/notificationAction2"
layout="@layout/settings_notification_action"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/notificationAction1" />
<include
android:id="@+id/notificationAction2"
layout="@layout/settings_notification_action"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/notificationAction1" />
<include
android:id="@+id/notificationAction3"
layout="@layout/settings_notification_action"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/notificationAction2" />
<include
android:id="@+id/notificationAction3"
layout="@layout/settings_notification_action"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/notificationAction2" />
<include
android:id="@+id/notificationAction4"
layout="@layout/settings_notification_action"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/notificationAction3" />
<include
android:id="@+id/notificationAction4"
layout="@layout/settings_notification_action"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/notificationAction3" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -57,7 +57,8 @@
<string name="notification_action_2_title">Third action button</string>
<string name="notification_action_3_title">Fourth action button</string>
<string name="notification_action_4_title">Fifth action button</string>
<string name="notification_actions_summary">Edit each notification action below by tapping on it. Select up to three of them to be shown in the compact notification by using the checkboxes on the right</string>
<string name="notification_actions_summary">Edit each notification action below by tapping on it. Select up to three of them to be shown in the compact notification by using the checkboxes on the right.</string>
<string name="notification_actions_summary_android13">Edit each notification action below by tapping on it. The first three actions (play/pause, previous and next) are set by the system and cannot be customized.</string>
<string name="notification_actions_at_most_three">You can select at most three actions to show in the compact notification!</string>
<string name="notification_action_repeat">Repeat</string>
<string name="notification_action_shuffle">Shuffle</string>