diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java index 6f76a91d1..737ebc5dd 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java @@ -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 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 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(); + } } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.java new file mode 100644 index 000000000..a5c9fccc9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.java @@ -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; + + 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(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.java new file mode 100644 index 000000000..b3abcd0b5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.java @@ -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); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java index 89bf0b22a..b9607f7ea 100644 --- a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java +++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java @@ -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 getCompactSlotsFromPreferences( + public static Collection getCompactSlotsFromPreferences( @NonNull final Context context, - final SharedPreferences sharedPreferences, - final int slotCount) { + final SharedPreferences sharedPreferences) { final SortedSet 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; } } diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java index 3fa7c2623..30420b0c7 100644 --- a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java +++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java @@ -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 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 settingsCompactSlots = NotificationConstants + .getCompactSlotsFromPreferences(player.getContext(), player.getPrefs()); + final List 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() { diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java index 3e92f297e..7dfddef20 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java +++ b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java @@ -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 compactSlots = null; + private NotificationSlot[] notificationSlots; + private List 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); - } - } - } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.java b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.java new file mode 100644 index 000000000..981ba3e75 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.java @@ -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 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 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; + } +} diff --git a/app/src/main/res/layout/settings_notification.xml b/app/src/main/res/layout/settings_notification.xml index d4af3fe71..b8195f18c 100644 --- a/app/src/main/res/layout/settings_notification.xml +++ b/app/src/main/res/layout/settings_notification.xml @@ -1,5 +1,4 @@ - + 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" /> - + - + - + - + - + - + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ab80c3eb5..281df95a4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -57,7 +57,8 @@ Third action button Fourth action button Fifth action button - 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 + 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. + 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. You can select at most three actions to show in the compact notification! Repeat Shuffle