Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Android 13+ custom notification actions #10580

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,14 +16,20 @@
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.ArrayList;
import java.util.List;
import java.util.Optional;

public class MediaSessionPlayerUi extends PlayerUi
Expand Down Expand Up @@ -163,4 +171,101 @@ private MediaMetadataCompat buildMediaMetadata() {

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. In order to allow customizing 4
// actions instead of just 2, we tell the system that the player cannot handle "previous"
// and "next" in PlayQueueNavigator.getSupportedQueueNavigatorActions(), as a workaround.
// The play-pause-buffering action instead cannot be replaced by a custom action even with
// workarounds, so we'll not be able to customize that.

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;
}

final List<SessionConnectorActionProvider> actions = new ArrayList<>(5);
for (int i = 0; i < 5; ++i) {
final int action = player.getPrefs().getInt(
player.getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]),
NotificationConstants.SLOT_DEFAULTS[i]);
if (action == NotificationConstants.PLAY_PAUSE
|| action == NotificationConstants.PLAY_PAUSE_BUFFERING) {
// play-pause and play-pause-buffering actions are already shown by the system
// in the notification on
continue;
}

@Nullable final NotificationActionData data =
NotificationActionData.fromNotificationActionEnum(player, action);

if (data != null) {
actions.add(new SessionConnectorActionProvider(data, context));
}
}

sessionConnector.setCustomActionProviders(
actions.toArray(new MediaSessionConnector.CustomActionProvider[0]));
}

// no need to override onPlaying, onBuffered and onPaused, since the play-pause and
// play-pause-buffering actions are skipped by updateMediaSessionActions anyway

@Override
public void onBlocked() {
super.onBlocked();
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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM;

import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.ResultReceiver;
import android.support.v4.media.MediaDescriptionCompat;
Expand Down Expand Up @@ -46,7 +47,16 @@ public PlayQueueNavigator(@NonNull final MediaSessionCompat mediaSession,
@Override
public long getSupportedQueueNavigatorActions(
@Nullable final com.google.android.exoplayer2.Player exoPlayer) {
return ACTION_SKIP_TO_NEXT | ACTION_SKIP_TO_PREVIOUS | ACTION_SKIP_TO_QUEUE_ITEM;
// As documented in
// https://developer.android.com/about/versions/13/behavior-changes-13#playback-controls
// starting with android 13, setting ACTION_SKIP_TO_PREVIOUS and ACTION_SKIP_TO_NEXT forces
// buttons 2 and 3 to be the system provided "Previous" and "Next".
// Thus, we pretend to not support those actions to have the ability to customize them in
// MediaSessionPlayerUi.updateMediaSessionActions().
return ACTION_SKIP_TO_QUEUE_ITEM
| (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
? ACTION_SKIP_TO_NEXT | ACTION_SKIP_TO_PREVIOUS
: 0);
Comment on lines +50 to +59
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this going to hurt Bluetooth media controls like double pressing the pause button on a headset to skip to the next item?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this change prevents to do the action you described, I just tested the artifact built by the CI with a Bluetooth speaker.

}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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) {
if (data.action() == null) {
return null;
} else {
return new PlaybackStateCompat.CustomAction.Builder(
data.action(), data.name(), data.icon()
).build();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
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.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;

public final class NotificationActionData {
@Nullable
private final String action;
@NonNull
private final String name;
@DrawableRes
private final int icon;

public NotificationActionData(@Nullable final String action, @NonNull final String name,
@DrawableRes final int icon) {
this.action = action;
this.name = name;
this.icon = icon;
}

@Nullable
public String action() {
return action;
}

@NonNull
public String name() {
return name;
}

@DrawableRes
public int icon() {
return icon;
}


@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) {
// null intent action -> show hourglass icon that does nothing when clicked
return new NotificationActionData(null,
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;
}
}
}
Loading
Loading