Emit onPositionDiscontinuity event when silence is skipped
Issue: androidx/media#765 PiperOrigin-RevId: 584024654
This commit is contained in:
parent
5eb6a889a7
commit
89bedf0fb5
@ -22,6 +22,8 @@
|
||||
`ImageRenderer.ImageOutput`.
|
||||
* `DefaultRenderersFactory` now provides an `ImageRenderer` to the player
|
||||
by default with null `ImageOutput` and `ImageDecoder.Factory.DEFAULT`.
|
||||
* Emit `Player.Listener.onPositionDiscontinuity` event when silence is
|
||||
skipped ([#765](https://github.com/androidx/media/issues/765)).
|
||||
* Transformer:
|
||||
* Add support for flattening H.265/HEVC SEF slow motion videos.
|
||||
* Increase transmuxing speed, especially for 'remove video' edits.
|
||||
|
3
api.txt
3
api.txt
@ -826,6 +826,7 @@ package androidx.media3.common {
|
||||
field public static final int DISCONTINUITY_REASON_REMOVE = 4; // 0x4
|
||||
field public static final int DISCONTINUITY_REASON_SEEK = 1; // 0x1
|
||||
field public static final int DISCONTINUITY_REASON_SEEK_ADJUSTMENT = 2; // 0x2
|
||||
field public static final int DISCONTINUITY_REASON_SILENCE_SKIP = 6; // 0x6
|
||||
field public static final int DISCONTINUITY_REASON_SKIP = 3; // 0x3
|
||||
field public static final int EVENT_AUDIO_ATTRIBUTES_CHANGED = 20; // 0x14
|
||||
field public static final int EVENT_AUDIO_SESSION_ID = 21; // 0x15
|
||||
@ -894,7 +895,7 @@ package androidx.media3.common {
|
||||
field public static final androidx.media3.common.Player.Commands EMPTY;
|
||||
}
|
||||
|
||||
@IntDef({androidx.media3.common.Player.DISCONTINUITY_REASON_AUTO_TRANSITION, androidx.media3.common.Player.DISCONTINUITY_REASON_SEEK, androidx.media3.common.Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT, androidx.media3.common.Player.DISCONTINUITY_REASON_SKIP, androidx.media3.common.Player.DISCONTINUITY_REASON_REMOVE, androidx.media3.common.Player.DISCONTINUITY_REASON_INTERNAL}) @java.lang.annotation.Documented @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) @java.lang.annotation.Target({java.lang.annotation.ElementType.FIELD, java.lang.annotation.ElementType.METHOD, java.lang.annotation.ElementType.PARAMETER, java.lang.annotation.ElementType.LOCAL_VARIABLE, java.lang.annotation.ElementType.TYPE_USE}) public static @interface Player.DiscontinuityReason {
|
||||
@IntDef({androidx.media3.common.Player.DISCONTINUITY_REASON_AUTO_TRANSITION, androidx.media3.common.Player.DISCONTINUITY_REASON_SEEK, androidx.media3.common.Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT, androidx.media3.common.Player.DISCONTINUITY_REASON_SKIP, androidx.media3.common.Player.DISCONTINUITY_REASON_REMOVE, androidx.media3.common.Player.DISCONTINUITY_REASON_INTERNAL, androidx.media3.common.Player.DISCONTINUITY_REASON_SILENCE_SKIP}) @java.lang.annotation.Documented @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) @java.lang.annotation.Target({java.lang.annotation.ElementType.FIELD, java.lang.annotation.ElementType.METHOD, java.lang.annotation.ElementType.PARAMETER, java.lang.annotation.ElementType.LOCAL_VARIABLE, java.lang.annotation.ElementType.TYPE_USE}) public static @interface Player.DiscontinuityReason {
|
||||
}
|
||||
|
||||
@IntDef({androidx.media3.common.Player.EVENT_TIMELINE_CHANGED, androidx.media3.common.Player.EVENT_MEDIA_ITEM_TRANSITION, androidx.media3.common.Player.EVENT_TRACKS_CHANGED, androidx.media3.common.Player.EVENT_IS_LOADING_CHANGED, androidx.media3.common.Player.EVENT_PLAYBACK_STATE_CHANGED, androidx.media3.common.Player.EVENT_PLAY_WHEN_READY_CHANGED, androidx.media3.common.Player.EVENT_PLAYBACK_SUPPRESSION_REASON_CHANGED, androidx.media3.common.Player.EVENT_IS_PLAYING_CHANGED, androidx.media3.common.Player.EVENT_REPEAT_MODE_CHANGED, androidx.media3.common.Player.EVENT_SHUFFLE_MODE_ENABLED_CHANGED, androidx.media3.common.Player.EVENT_PLAYER_ERROR, androidx.media3.common.Player.EVENT_POSITION_DISCONTINUITY, androidx.media3.common.Player.EVENT_PLAYBACK_PARAMETERS_CHANGED, androidx.media3.common.Player.EVENT_AVAILABLE_COMMANDS_CHANGED, androidx.media3.common.Player.EVENT_MEDIA_METADATA_CHANGED, androidx.media3.common.Player.EVENT_PLAYLIST_METADATA_CHANGED, androidx.media3.common.Player.EVENT_SEEK_BACK_INCREMENT_CHANGED, androidx.media3.common.Player.EVENT_SEEK_FORWARD_INCREMENT_CHANGED, androidx.media3.common.Player.EVENT_MAX_SEEK_TO_PREVIOUS_POSITION_CHANGED, androidx.media3.common.Player.EVENT_TRACK_SELECTION_PARAMETERS_CHANGED, androidx.media3.common.Player.EVENT_AUDIO_ATTRIBUTES_CHANGED, androidx.media3.common.Player.EVENT_AUDIO_SESSION_ID, androidx.media3.common.Player.EVENT_VOLUME_CHANGED, androidx.media3.common.Player.EVENT_SKIP_SILENCE_ENABLED_CHANGED, androidx.media3.common.Player.EVENT_SURFACE_SIZE_CHANGED, androidx.media3.common.Player.EVENT_VIDEO_SIZE_CHANGED, androidx.media3.common.Player.EVENT_RENDERED_FIRST_FRAME, androidx.media3.common.Player.EVENT_CUES, androidx.media3.common.Player.EVENT_METADATA, androidx.media3.common.Player.EVENT_DEVICE_INFO_CHANGED, androidx.media3.common.Player.EVENT_DEVICE_VOLUME_CHANGED}) @java.lang.annotation.Documented @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) @java.lang.annotation.Target({java.lang.annotation.ElementType.FIELD, java.lang.annotation.ElementType.METHOD, java.lang.annotation.ElementType.PARAMETER, java.lang.annotation.ElementType.LOCAL_VARIABLE, java.lang.annotation.ElementType.TYPE_USE}) public static @interface Player.Event {
|
||||
|
@ -1395,7 +1395,8 @@ public interface Player {
|
||||
DISCONTINUITY_REASON_SEEK_ADJUSTMENT,
|
||||
DISCONTINUITY_REASON_SKIP,
|
||||
DISCONTINUITY_REASON_REMOVE,
|
||||
DISCONTINUITY_REASON_INTERNAL
|
||||
DISCONTINUITY_REASON_INTERNAL,
|
||||
DISCONTINUITY_REASON_SILENCE_SKIP
|
||||
})
|
||||
@interface DiscontinuityReason {}
|
||||
|
||||
@ -1427,6 +1428,9 @@ public interface Player {
|
||||
/** Discontinuity introduced internally (e.g. by the source). */
|
||||
int DISCONTINUITY_REASON_INTERNAL = 5;
|
||||
|
||||
/** Discontinuity introduced by a skipped silence. */
|
||||
int DISCONTINUITY_REASON_SILENCE_SKIP = 6;
|
||||
|
||||
/**
|
||||
* Reasons for timeline changes. One of {@link #TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED} or {@link
|
||||
* #TIMELINE_CHANGE_REASON_SOURCE_UPDATE}.
|
||||
|
@ -134,6 +134,13 @@ import androidx.media3.common.util.Clock;
|
||||
: Assertions.checkNotNull(rendererClock).getPositionUs();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasSkippedSilenceSinceLastCall() {
|
||||
return isUsingStandaloneClock
|
||||
? standaloneClock.hasSkippedSilenceSinceLastCall()
|
||||
: Assertions.checkNotNull(rendererClock).hasSkippedSilenceSinceLastCall();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPlaybackParameters(PlaybackParameters playbackParameters) {
|
||||
if (rendererClock != null) {
|
||||
|
@ -993,7 +993,18 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
/* isReadingAhead= */ playingPeriodHolder != queue.getReadingPeriod());
|
||||
long periodPositionUs = playingPeriodHolder.toPeriodTime(rendererPositionUs);
|
||||
maybeTriggerPendingMessages(playbackInfo.positionUs, periodPositionUs);
|
||||
playbackInfo.updatePositionUs(periodPositionUs);
|
||||
if (mediaClock.hasSkippedSilenceSinceLastCall()) {
|
||||
playbackInfo =
|
||||
handlePositionDiscontinuity(
|
||||
playbackInfo.periodId,
|
||||
/* positionUs= */ periodPositionUs,
|
||||
playbackInfo.requestedContentPositionUs,
|
||||
/* discontinuityStartPositionUs= */ periodPositionUs,
|
||||
/* reportDiscontinuity= */ true,
|
||||
Player.DISCONTINUITY_REASON_SILENCE_SKIP);
|
||||
} else {
|
||||
playbackInfo.updatePositionUs(periodPositionUs);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the buffered position and total buffered duration.
|
||||
|
@ -25,6 +25,11 @@ public interface MediaClock {
|
||||
/** Returns the current media position in microseconds. */
|
||||
long getPositionUs();
|
||||
|
||||
/** Returns whether there is a skipped silence since the last call to this method. */
|
||||
default boolean hasSkippedSilenceSinceLastCall() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to set the playback parameters. The media clock may override the speed if changing the
|
||||
* playback parameters is not supported.
|
||||
|
@ -152,6 +152,9 @@ public interface AudioSink {
|
||||
* @param audioTrackConfig The {@link AudioTrackConfig} of the released {@link AudioTrack}.
|
||||
*/
|
||||
default void onAudioTrackReleased(AudioTrackConfig audioTrackConfig) {}
|
||||
|
||||
/** Called when a period of silence has been skipped. */
|
||||
default void onSilenceSkipped() {}
|
||||
}
|
||||
|
||||
/** Configuration parameters used for an {@link AudioTrack}. */
|
||||
|
@ -165,6 +165,7 @@ public abstract class DecoderAudioRenderer<
|
||||
private long outputStreamOffsetUs;
|
||||
private final long[] pendingOutputStreamOffsetsUs;
|
||||
private int pendingOutputStreamOffsetCount;
|
||||
private boolean hasPendingReportedSkippedSilence;
|
||||
|
||||
public DecoderAudioRenderer() {
|
||||
this(/* eventHandler= */ null, /* eventListener= */ null);
|
||||
@ -577,6 +578,13 @@ public abstract class DecoderAudioRenderer<
|
||||
return currentPositionUs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasSkippedSilenceSinceLastCall() {
|
||||
boolean hasPendingReportedSkippedSilence = this.hasPendingReportedSkippedSilence;
|
||||
this.hasPendingReportedSkippedSilence = false;
|
||||
return hasPendingReportedSkippedSilence;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPlaybackParameters(PlaybackParameters playbackParameters) {
|
||||
audioSink.setPlaybackParameters(playbackParameters);
|
||||
@ -606,6 +614,7 @@ public abstract class DecoderAudioRenderer<
|
||||
audioSink.flush();
|
||||
|
||||
currentPositionUs = positionUs;
|
||||
hasPendingReportedSkippedSilence = false;
|
||||
allowPositionDiscontinuity = true;
|
||||
inputStreamEnded = false;
|
||||
outputStreamEnded = false;
|
||||
@ -630,6 +639,7 @@ public abstract class DecoderAudioRenderer<
|
||||
inputFormat = null;
|
||||
audioTrackNeedsConfigure = true;
|
||||
setOutputStreamOffsetUs(C.TIME_UNSET);
|
||||
hasPendingReportedSkippedSilence = false;
|
||||
try {
|
||||
setSourceDrmSession(null);
|
||||
releaseDecoder();
|
||||
@ -829,6 +839,11 @@ public abstract class DecoderAudioRenderer<
|
||||
DecoderAudioRenderer.this.onPositionDiscontinuity();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSilenceSkipped() {
|
||||
hasPendingReportedSkippedSilence = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPositionAdvancing(long playoutStartSystemTimeMs) {
|
||||
eventDispatcher.positionAdvancing(playoutStartSystemTimeMs);
|
||||
|
@ -96,6 +96,16 @@ public final class DefaultAudioSink implements AudioSink {
|
||||
*/
|
||||
private static final int AUDIO_TRACK_SMALLER_BUFFER_RETRY_SIZE = 1_000_000;
|
||||
|
||||
/** The minimum duration of the skipped silence to be reported as discontinuity. */
|
||||
private static final int MINIMUM_REPORT_SKIPPED_SILENCE_DURATION_US = 1_000_000;
|
||||
|
||||
/**
|
||||
* The delay of reporting the skipped silence, during which the default audio sink checks if there
|
||||
* is any further skipped silence that is close to the delayed silence. If any, the further
|
||||
* skipped silence will be concatenated to the delayed one.
|
||||
*/
|
||||
private static final int REPORT_SKIPPED_SILENCE_DELAY_MS = 100;
|
||||
|
||||
/**
|
||||
* Thrown when the audio track has provided a spurious timestamp, if {@link
|
||||
* #failOnSpuriousAudioTimestamp} is set.
|
||||
@ -542,6 +552,9 @@ public final class DefaultAudioSink implements AudioSink {
|
||||
private boolean offloadDisabledUntilNextConfiguration;
|
||||
private boolean isWaitingForOffloadEndOfStreamHandled;
|
||||
@Nullable private Looper playbackLooper;
|
||||
private long skippedOutputFrameCountAtLastPosition;
|
||||
private long accumulatedSkippedSilenceDurationUs;
|
||||
private @MonotonicNonNull Handler reportSkippedSilenceHandler;
|
||||
|
||||
@RequiresNonNull("#1.audioProcessorChain")
|
||||
private DefaultAudioSink(Builder builder) {
|
||||
@ -1443,6 +1456,11 @@ public final class DefaultAudioSink implements AudioSink {
|
||||
}
|
||||
writeExceptionPendingExceptionHolder.clear();
|
||||
initializationExceptionPendingExceptionHolder.clear();
|
||||
skippedOutputFrameCountAtLastPosition = 0;
|
||||
accumulatedSkippedSilenceDurationUs = 0;
|
||||
if (reportSkippedSilenceHandler != null) {
|
||||
checkNotNull(reportSkippedSilenceHandler).removeCallbacksAndMessages(null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -1645,8 +1663,28 @@ public final class DefaultAudioSink implements AudioSink {
|
||||
}
|
||||
|
||||
private long applySkipping(long positionUs) {
|
||||
return positionUs
|
||||
+ configuration.framesToDurationUs(audioProcessorChain.getSkippedOutputFrameCount());
|
||||
long skippedOutputFrameCountAtCurrentPosition =
|
||||
audioProcessorChain.getSkippedOutputFrameCount();
|
||||
long adjustedPositionUs =
|
||||
positionUs + configuration.framesToDurationUs(skippedOutputFrameCountAtCurrentPosition);
|
||||
if (skippedOutputFrameCountAtCurrentPosition > skippedOutputFrameCountAtLastPosition) {
|
||||
long silenceDurationUs =
|
||||
configuration.framesToDurationUs(
|
||||
skippedOutputFrameCountAtCurrentPosition - skippedOutputFrameCountAtLastPosition);
|
||||
skippedOutputFrameCountAtLastPosition = skippedOutputFrameCountAtCurrentPosition;
|
||||
handleSkippedSilence(silenceDurationUs);
|
||||
}
|
||||
return adjustedPositionUs;
|
||||
}
|
||||
|
||||
private void handleSkippedSilence(long silenceDurationUs) {
|
||||
accumulatedSkippedSilenceDurationUs += silenceDurationUs;
|
||||
if (reportSkippedSilenceHandler == null) {
|
||||
reportSkippedSilenceHandler = new Handler(Looper.myLooper());
|
||||
}
|
||||
reportSkippedSilenceHandler.removeCallbacksAndMessages(null);
|
||||
reportSkippedSilenceHandler.postDelayed(
|
||||
this::maybeReportSkippedSilence, /* delayMillis= */ REPORT_SKIPPED_SILENCE_DELAY_MS);
|
||||
}
|
||||
|
||||
private boolean isAudioTrackInitialized() {
|
||||
@ -2225,6 +2263,16 @@ public final class DefaultAudioSink implements AudioSink {
|
||||
}
|
||||
}
|
||||
|
||||
private void maybeReportSkippedSilence() {
|
||||
if (accumulatedSkippedSilenceDurationUs >= MINIMUM_REPORT_SKIPPED_SILENCE_DURATION_US) {
|
||||
// If the existing silence is already long enough, report the silence
|
||||
listener.onSilenceSkipped();
|
||||
}
|
||||
// Reset the accumulated silence anyway as the later silences are far from the current one
|
||||
// and should be treated separately.
|
||||
accumulatedSkippedSilenceDurationUs = 0;
|
||||
}
|
||||
|
||||
@RequiresApi(23)
|
||||
private static final class AudioDeviceInfoApi23 {
|
||||
|
||||
|
@ -118,6 +118,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media
|
||||
private boolean audioSinkNeedsReset;
|
||||
|
||||
@Nullable private WakeupListener wakeupListener;
|
||||
private boolean hasPendingReportedSkippedSilence;
|
||||
|
||||
/**
|
||||
* @param context A context.
|
||||
@ -613,6 +614,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media
|
||||
audioSink.flush();
|
||||
|
||||
currentPositionUs = positionUs;
|
||||
hasPendingReportedSkippedSilence = false;
|
||||
allowPositionDiscontinuity = true;
|
||||
}
|
||||
|
||||
@ -646,6 +648,7 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media
|
||||
|
||||
@Override
|
||||
protected void onReset() {
|
||||
hasPendingReportedSkippedSilence = false;
|
||||
try {
|
||||
super.onReset();
|
||||
} finally {
|
||||
@ -679,6 +682,13 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media
|
||||
return currentPositionUs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasSkippedSilenceSinceLastCall() {
|
||||
boolean hasPendingReportedSkippedSilence = this.hasPendingReportedSkippedSilence;
|
||||
this.hasPendingReportedSkippedSilence = false;
|
||||
return hasPendingReportedSkippedSilence;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPlaybackParameters(PlaybackParameters playbackParameters) {
|
||||
audioSink.setPlaybackParameters(playbackParameters);
|
||||
@ -969,6 +979,11 @@ public class MediaCodecAudioRenderer extends MediaCodecRenderer implements Media
|
||||
MediaCodecAudioRenderer.this.onPositionDiscontinuity();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSilenceSkipped() {
|
||||
hasPendingReportedSkippedSilence = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPositionAdvancing(long playoutStartSystemTimeMs) {
|
||||
eventDispatcher.positionAdvancing(playoutStartSystemTimeMs);
|
||||
|
@ -707,6 +707,8 @@ public class EventLogger implements AnalyticsListener {
|
||||
return "SKIP";
|
||||
case Player.DISCONTINUITY_REASON_INTERNAL:
|
||||
return "INTERNAL";
|
||||
case Player.DISCONTINUITY_REASON_SILENCE_SKIP:
|
||||
return "SILENCE_SKIP";
|
||||
default:
|
||||
return "?";
|
||||
}
|
||||
|
@ -14054,6 +14054,80 @@ public final class ExoPlayerTest {
|
||||
.isSameInstanceAs(mediaItem2);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void silenceSkipped_playerEmitOnPositionDiscontinuity() throws Exception {
|
||||
Timeline timeline =
|
||||
new FakeTimeline(
|
||||
new TimelineWindowDefinition(
|
||||
/* periodCount= */ 1,
|
||||
/* id= */ 0,
|
||||
/* isSeekable= */ true,
|
||||
/* isDynamic= */ false,
|
||||
/* isLive= */ false,
|
||||
/* isPlaceholder= */ false,
|
||||
/* durationUs= */ 10 * C.MICROS_PER_SECOND,
|
||||
/* defaultPositionUs= */ 0,
|
||||
/* windowOffsetInFirstPeriodUs= */ 0,
|
||||
AdPlaybackState.NONE));
|
||||
FakeMediaClockRenderer audioRenderer =
|
||||
new FakeMediaClockRenderer(C.TRACK_TYPE_AUDIO) {
|
||||
private long offsetUs;
|
||||
private long positionUs;
|
||||
private boolean hasPendingReportedSkippedSilence;
|
||||
|
||||
@Override
|
||||
protected void onStreamChanged(
|
||||
Format[] formats, long startPositionUs, long offsetUs, MediaPeriodId mediaPeriodId) {
|
||||
this.offsetUs = offsetUs;
|
||||
this.positionUs = offsetUs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getPositionUs() {
|
||||
// Continuously increase position to let playback progress, and simulate the silence
|
||||
// skip until it reaches some points of time.
|
||||
if (positionUs - offsetUs == 10_000) {
|
||||
hasPendingReportedSkippedSilence = true;
|
||||
positionUs += 30_000;
|
||||
} else {
|
||||
positionUs += 10_000;
|
||||
}
|
||||
return positionUs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasSkippedSilenceSinceLastCall() {
|
||||
boolean hasPendingReportedSkippedSilence = this.hasPendingReportedSkippedSilence;
|
||||
if (hasPendingReportedSkippedSilence) {
|
||||
this.hasPendingReportedSkippedSilence = false;
|
||||
}
|
||||
return hasPendingReportedSkippedSilence;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPlaybackParameters(PlaybackParameters playbackParameters) {}
|
||||
|
||||
@Override
|
||||
public PlaybackParameters getPlaybackParameters() {
|
||||
return PlaybackParameters.DEFAULT;
|
||||
}
|
||||
};
|
||||
ExoPlayer player = new TestExoPlayerBuilder(context).setRenderers(audioRenderer).build();
|
||||
Player.Listener mockPlayerListener = mock(Player.Listener.class);
|
||||
player.addListener(mockPlayerListener);
|
||||
|
||||
player.setMediaSource(new FakeMediaSource(timeline, ExoPlayerTestRunner.AUDIO_FORMAT));
|
||||
player.prepare();
|
||||
player.play();
|
||||
runUntilPlaybackState(player, Player.STATE_ENDED);
|
||||
|
||||
verify(mockPlayerListener)
|
||||
.onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_SILENCE_SKIP));
|
||||
assertThat(audioRenderer.isEnded).isTrue();
|
||||
|
||||
player.release();
|
||||
}
|
||||
|
||||
// Internal methods.
|
||||
|
||||
private void addWatchAsSystemFeature() {
|
||||
|
Loading…
x
Reference in New Issue
Block a user