Add episode count sleep timers (#7841)

This commit is contained in:
eblis
2025-10-30 23:51:03 +02:00
committed by GitHub
parent c122fa544d
commit 0debbc3973
20 changed files with 645 additions and 88 deletions

View File

@ -99,4 +99,20 @@ public class CancelablePSMPCallback implements PlaybackServiceMediaPlayer.PSMPCa
}
originalCallback.ensureMediaInfoLoaded(media);
}
@Override
public void episodeFinishedPlayback() {
if (isCancelled) {
return;
}
originalCallback.episodeFinishedPlayback();
}
@Override
public boolean shouldContinueToNextEpisode() {
if (isCancelled) {
return false;
}
return originalCallback.shouldContinueToNextEpisode();
}
}

View File

@ -56,4 +56,13 @@ public class DefaultPSMPCallback implements PlaybackServiceMediaPlayer.PSMPCallb
@Override
public void ensureMediaInfoLoaded(@NonNull Playable media) {
}
@Override
public void episodeFinishedPlayback() {
}
@Override
public boolean shouldContinueToNextEpisode() {
return false;
}
}

View File

@ -3,47 +3,92 @@ package de.danoeh.antennapod.ui.screen.playback;
import android.app.Activity;
import android.app.Dialog;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.text.format.DateFormat;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.Spinner;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.DialogFragment;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.snackbar.Snackbar;
import de.danoeh.antennapod.playback.base.PlayerStatus;
import de.danoeh.antennapod.playback.service.PlaybackController;
import de.danoeh.antennapod.playback.service.PlaybackService;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import de.danoeh.antennapod.R;
import de.danoeh.antennapod.storage.preferences.SleepTimerPreferences;
import de.danoeh.antennapod.ui.common.Converter;
import de.danoeh.antennapod.event.playback.SleepTimerUpdatedEvent;
import de.danoeh.antennapod.playback.base.PlayerStatus;
import de.danoeh.antennapod.playback.service.PlaybackController;
import de.danoeh.antennapod.playback.service.PlaybackService;
import de.danoeh.antennapod.storage.database.DBReader;
import de.danoeh.antennapod.storage.preferences.PlaybackPreferences;
import de.danoeh.antennapod.storage.preferences.SleepTimerPreferences;
import de.danoeh.antennapod.storage.preferences.SleepTimerType;
import de.danoeh.antennapod.storage.preferences.UserPreferences;
import de.danoeh.antennapod.ui.common.Converter;
import de.danoeh.antennapod.ui.common.ThemeUtils;
import de.danoeh.antennapod.ui.screen.preferences.PreferenceActivity;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class SleepTimerDialog extends DialogFragment {
private PlaybackController controller;
private EditText etxtTime;
private TextView sleepTimerHintText;
private LinearLayout timeSetup;
private LinearLayout timeDisplay;
private TextView time;
private Spinner sleepTimerType;
private CheckBox chAutoEnable;
private ImageView changeTimesButton;
private CheckBox cbVibrate;
private CheckBox cbShakeToReset;
private Button setTimerButton;
private Button playbackPreferencesButton;
private Disposable disposable;
private volatile Integer currentQueueSize = null;
Button extendSleepFiveMinutesButton;
Button extendSleepTenMinutesButton;
Button extendSleepTwentyMinutesButton;
private static final int EXTEND_FEW_MINUTES_DISPLAY_VALUE = 5;
private static final int EXTEND_FEW_MINUTES = 5 * 1000 * 60;
private static final int EXTEND_MID_MINUTES_DISPLAY_VALUE = 10;
private static final int EXTEND_MID_MINUTES = 10 * 1000 * 60;
private static final int EXTEND_LOTS_MINUTES_DISPLAY_VALUE = 30;
private static final int EXTEND_LOTS_MINUTES = 30 * 1000 * 60;
private static final int EXTEND_FEW_EPISODES = 1;
private static final int EXTEND_MID_EPISODES = 2;
private static final int EXTEND_LOTS_EPISODES = 3;
private static final int SLEEP_DURATION_DAILY_HOURS_CUTOFF = 12;
public SleepTimerDialog() {
}
@Override
@ -56,6 +101,12 @@ public class SleepTimerDialog extends DialogFragment {
};
controller.init();
EventBus.getDefault().register(this);
disposable = Single.fromCallable(() ->
DBReader.getRemainingQueueSize(PlaybackPreferences.getCurrentlyPlayingFeedMediaId()))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(result -> currentQueueSize = result);
}
@Override
@ -64,6 +115,10 @@ public class SleepTimerDialog extends DialogFragment {
if (controller != null) {
controller.release();
}
if (disposable != null) {
disposable.dispose();
disposable = null;
}
EventBus.getDefault().unregister(this);
}
@ -71,54 +126,102 @@ public class SleepTimerDialog extends DialogFragment {
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
View content = View.inflate(getContext(), R.layout.time_dialog, null);
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getContext());
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext());
builder.setTitle(R.string.sleep_timer_label);
builder.setView(content);
builder.setPositiveButton(R.string.close_label, null);
List<String> spinnerContent = new ArrayList<>();
// add "title" for all options
spinnerContent.add(getString(R.string.time_minutes));
spinnerContent.add(getString(R.string.sleep_timer_episodes_label));
ArrayAdapter<String> spinnerAdapter = new ArrayAdapter<>(
getContext(), android.R.layout.simple_spinner_item, spinnerContent);
spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
sleepTimerType = content.findViewById(R.id.sleepTimerType);
sleepTimerType.setAdapter(spinnerAdapter);
sleepTimerType.setSelection(SleepTimerPreferences.getSleepTimerType().index);
sleepTimerType.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
private boolean sleepTimerTypeInitialized = false;
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
final SleepTimerType sleepType = SleepTimerType.fromIndex(position);
SleepTimerPreferences.setSleepTimerType(sleepType);
// this callback is called even when the spinner is first initialized
// we need to differentiate these calls
if (sleepTimerTypeInitialized) {
// disable auto sleep timer if they've configured it for most of the day
if (isSleepTimerConfiguredForMostOfTheDay()) {
chAutoEnable.setChecked(false);
}
// change suggested value back to default value for sleep type
if (sleepType == SleepTimerType.EPISODES) {
etxtTime.setText(SleepTimerPreferences.DEFAULT_SLEEP_TIMER_EPISODES);
} else {
etxtTime.setText(SleepTimerPreferences.DEFAULT_SLEEP_TIMER_MINUTES);
}
}
sleepTimerTypeInitialized = true;
refreshUiState();
}
@Override
public void onNothingSelected(AdapterView<?> parent) {
}
});
etxtTime = content.findViewById(R.id.etxtTime);
sleepTimerHintText = content.findViewById(R.id.sleepTimerHintText);
timeSetup = content.findViewById(R.id.timeSetup);
timeDisplay = content.findViewById(R.id.timeDisplay);
timeDisplay.setVisibility(View.GONE);
time = content.findViewById(R.id.time);
Button extendSleepFiveMinutesButton = content.findViewById(R.id.extendSleepFiveMinutesButton);
extendSleepFiveMinutesButton.setText(getString(R.string.extend_sleep_timer_label, 5));
Button extendSleepTenMinutesButton = content.findViewById(R.id.extendSleepTenMinutesButton);
extendSleepTenMinutesButton.setText(getString(R.string.extend_sleep_timer_label, 10));
Button extendSleepTwentyMinutesButton = content.findViewById(R.id.extendSleepTwentyMinutesButton);
extendSleepTwentyMinutesButton.setText(getString(R.string.extend_sleep_timer_label, 20));
extendSleepFiveMinutesButton.setOnClickListener(v -> {
if (controller != null) {
controller.extendSleepTimer(5 * 1000 * 60);
cbShakeToReset = content.findViewById(R.id.cbShakeToReset);
cbVibrate = content.findViewById(R.id.cbVibrate);
chAutoEnable = content.findViewById(R.id.chAutoEnable);
changeTimesButton = content.findViewById(R.id.changeTimesButton);
setTimerButton = content.findViewById(R.id.setSleeptimerButton);
playbackPreferencesButton = content.findViewById(R.id.playbackPreferencesButton);
extendSleepFiveMinutesButton = content.findViewById(R.id.extendSleepFiveMinutesButton);
extendSleepTenMinutesButton = content.findViewById(R.id.extendSleepTenMinutesButton);
extendSleepTwentyMinutesButton = content.findViewById(R.id.extendSleepTwentyMinutesButton);
etxtTime.setText(SleepTimerPreferences.lastTimerValue());
etxtTime.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {
}
});
extendSleepTenMinutesButton.setOnClickListener(v -> {
if (controller != null) {
controller.extendSleepTimer(10 * 1000 * 60);
@Override
public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {
}
});
extendSleepTwentyMinutesButton.setOnClickListener(v -> {
if (controller != null) {
controller.extendSleepTimer(20 * 1000 * 60);
@Override
public void afterTextChanged(Editable editable) {
refreshUiState();
}
});
etxtTime.setText(SleepTimerPreferences.lastTimerValue());
playbackPreferencesButton.setOnClickListener(view -> {
final Intent playbackIntent = new Intent(getActivity(), PreferenceActivity.class);
playbackIntent.putExtra(PreferenceActivity.OPEN_PLAYBACK_SETTINGS, true);
startActivity(playbackIntent);
dismiss();
});
etxtTime.postDelayed(() -> {
InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
imm.showSoftInput(etxtTime, InputMethodManager.SHOW_IMPLICIT);
}, 100);
final CheckBox cbShakeToReset = content.findViewById(R.id.cbShakeToReset);
final CheckBox cbVibrate = content.findViewById(R.id.cbVibrate);
chAutoEnable = content.findViewById(R.id.chAutoEnable);
final ImageView changeTimesButton = content.findViewById(R.id.changeTimesButton);
refreshUiState();
chAutoEnable.setChecked(SleepTimerPreferences.autoEnable());
cbShakeToReset.setChecked(SleepTimerPreferences.shakeToReset());
cbVibrate.setChecked(SleepTimerPreferences.vibrate());
chAutoEnable.setChecked(SleepTimerPreferences.autoEnable());
changeTimesButton.setEnabled(chAutoEnable.isChecked());
changeTimesButton.setAlpha(chAutoEnable.isChecked() ? 1.0f : 0.5f);
refreshAutoEnableControls(SleepTimerPreferences.autoEnable());
cbShakeToReset.setOnCheckedChangeListener((buttonView, isChecked)
-> SleepTimerPreferences.setShakeToReset(isChecked));
@ -126,9 +229,11 @@ public class SleepTimerDialog extends DialogFragment {
-> SleepTimerPreferences.setVibrate(isChecked));
chAutoEnable.setOnCheckedChangeListener((compoundButton, isChecked)
-> {
SleepTimerPreferences.setAutoEnable(isChecked);
changeTimesButton.setEnabled(isChecked);
changeTimesButton.setAlpha(isChecked ? 1.0f : 0.5f);
boolean mostOfDay = isSleepTimerConfiguredForMostOfTheDay();
if (isChecked && mostOfDay && SleepTimerPreferences.getSleepTimerType() == SleepTimerType.EPISODES) {
confirmAlwaysSleepTimerDialog();
}
refreshAutoEnableControls(isChecked);
});
updateAutoEnableText();
@ -144,21 +249,17 @@ public class SleepTimerDialog extends DialogFragment {
controller.disableSleepTimer();
}
});
Button setButton = content.findViewById(R.id.setSleeptimerButton);
setButton.setOnClickListener(v -> {
setTimerButton.setOnClickListener(v -> {
if (!PlaybackService.isRunning
|| (controller != null && controller.getStatus() != PlayerStatus.PLAYING)) {
Snackbar.make(content, R.string.no_media_playing_label, Snackbar.LENGTH_LONG).show();
return;
}
try {
long time = Long.parseLong(etxtTime.getText().toString());
if (time == 0) {
throw new NumberFormatException("Timer must not be zero");
}
SleepTimerPreferences.setLastTimer(etxtTime.getText().toString());
SleepTimerPreferences.setLastTimer("" + getSelectedSleepTime());
if (controller != null) {
controller.setSleepTimer(SleepTimerPreferences.timerMillis());
controller.setSleepTimer(SleepTimerPreferences.timerMillisOrEpisodes());
}
closeKeyboard(content);
} catch (NumberFormatException e) {
@ -169,11 +270,161 @@ public class SleepTimerDialog extends DialogFragment {
return builder.create();
}
private boolean isSleepTimerConfiguredForMostOfTheDay() {
return SleepTimerPreferences.autoEnableDuration() > SLEEP_DURATION_DAILY_HOURS_CUTOFF;
}
private void confirmAlwaysSleepTimerDialog() {
AlertDialog dialog = new MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.sleep_timer_without_continuous_playback)
.setMessage(R.string.sleep_timer_without_continuous_playback_message)
.setNegativeButton(R.string.sleep_timer_without_continuous_playback,
(dialogInterface, i) -> {
// disable continuous playback and also disable the auto sleep timer
UserPreferences.setFollowQueue(false);
chAutoEnable.setChecked(false);
refreshUiState();
})
.setPositiveButton(R.string.sleep_timer_without_continuous_playback_change_hours,
(dialogInterface, i) -> {
int from = SleepTimerPreferences.autoEnableFrom();
int to = SleepTimerPreferences.autoEnableTo();
showTimeRangeDialog(getContext(), from, to);
})
.create();
dialog.setOnCancelListener(dialogInterface -> chAutoEnable.setChecked(false));
dialog.show();
// mark the disable continuous playback option in red
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)
.setTextColor(ThemeUtils.getColorFromAttr(requireContext(), R.attr.colorError));
}
private long getSelectedSleepTime() throws NumberFormatException {
long time = Long.parseLong(etxtTime.getText().toString());
if (time == 0) {
throw new NumberFormatException("Timer must not be zero");
}
return time;
}
private void refreshAutoEnableControls(boolean enabled) {
SleepTimerPreferences.setAutoEnable(enabled);
changeTimesButton.setAlpha(enabled ? 1.0f : 0.5f);
}
private void refreshUiState() {
// if we're using episode timer and continuous playback is disabled, don't
// let the user use anything other than 1 episode
boolean isEpisodeType = SleepTimerPreferences.getSleepTimerType() == SleepTimerType.EPISODES;
boolean noEpisodeSelection = isEpisodeType && !UserPreferences.isFollowQueue();
if (noEpisodeSelection) {
etxtTime.setEnabled(false);
playbackPreferencesButton.setVisibility(View.VISIBLE);
sleepTimerHintText.setText(R.string.multiple_sleep_episodes_while_continuous_playback_disabled);
sleepTimerHintText.setVisibility(View.VISIBLE);
chAutoEnable.setVisibility(View.GONE);
changeTimesButton.setVisibility(View.GONE);
cbShakeToReset.setVisibility(View.GONE);
cbVibrate.setVisibility(View.GONE);
setTimerButton.setEnabled(false);
} else {
playbackPreferencesButton.setVisibility(View.GONE);
chAutoEnable.setVisibility(View.VISIBLE);
changeTimesButton.setVisibility(View.VISIBLE);
cbShakeToReset.setVisibility(isEpisodeType ? View.GONE : View.VISIBLE);
cbVibrate.setVisibility(View.VISIBLE);
setTimerButton.setEnabled(true);
etxtTime.setEnabled(true);
long selectedSleepTime;
try {
selectedSleepTime = getSelectedSleepTime();
} catch (NumberFormatException nex) {
selectedSleepTime = 0;
}
if (isEpisodeType) {
// for episode timers check if the queue length exceeds the number of sleep episodes we have
if (currentQueueSize != null && selectedSleepTime > currentQueueSize) {
sleepTimerHintText.setText(getResources().getQuantityString(
R.plurals.episodes_sleep_timer_exceeds_queue,
currentQueueSize,
currentQueueSize));
sleepTimerHintText.setVisibility(View.VISIBLE);
} else {
sleepTimerHintText.setVisibility(View.GONE); // could maybe show duration in minutes
}
} else {
// for time sleep timers check if the selected value exceeds the remaining play time in the episode
final int remaining = controller != null ? controller.getDuration() - controller.getPosition() :
Integer.MAX_VALUE;
final long timer = TimeUnit.MINUTES.toMillis(selectedSleepTime);
if ((timer > remaining) && !UserPreferences.isFollowQueue()) {
final int remainingMinutes = Math.toIntExact(TimeUnit.MILLISECONDS.toMinutes(remaining));
sleepTimerHintText
.setText(getResources().getQuantityString(
R.plurals.timer_exceeds_remaining_time_while_continuous_playback_disabled,
remainingMinutes,
remainingMinutes
));
sleepTimerHintText.setVisibility(View.VISIBLE);
} else {
// don't show it at all
sleepTimerHintText.setVisibility(View.GONE);
}
}
}
// disable extension for episodes if we're not moving to next one
extendSleepFiveMinutesButton.setEnabled(!noEpisodeSelection);
extendSleepTenMinutesButton.setEnabled(!noEpisodeSelection);
extendSleepTwentyMinutesButton.setEnabled(!noEpisodeSelection);
if (SleepTimerPreferences.getSleepTimerType() == SleepTimerType.CLOCK) {
setupExtendButton(extendSleepFiveMinutesButton,
getString(R.string.extend_sleep_timer_label, EXTEND_FEW_MINUTES_DISPLAY_VALUE),
EXTEND_FEW_MINUTES);
setupExtendButton(extendSleepTenMinutesButton,
getString(R.string.extend_sleep_timer_label, EXTEND_MID_MINUTES_DISPLAY_VALUE),
EXTEND_MID_MINUTES);
setupExtendButton(extendSleepTwentyMinutesButton,
getString(R.string.extend_sleep_timer_label, EXTEND_LOTS_MINUTES_DISPLAY_VALUE),
EXTEND_LOTS_MINUTES);
} else {
setupExtendButton(extendSleepFiveMinutesButton,
"+" + getResources().getQuantityString(R.plurals.num_episodes,
EXTEND_FEW_EPISODES, EXTEND_FEW_EPISODES), EXTEND_FEW_EPISODES);
setupExtendButton(extendSleepTenMinutesButton,
"+" + getResources().getQuantityString(R.plurals.num_episodes,
EXTEND_MID_EPISODES, EXTEND_MID_EPISODES), EXTEND_MID_EPISODES);
setupExtendButton(extendSleepTwentyMinutesButton,
"+" + getResources().getQuantityString(R.plurals.num_episodes,
EXTEND_LOTS_EPISODES, EXTEND_LOTS_EPISODES), EXTEND_LOTS_EPISODES);
}
}
void setupExtendButton(TextView button, String text, int extendValue) {
button.setText(text);
button.setOnClickListener(v -> {
if (controller != null) {
controller.extendSleepTimer(extendValue);
}
});
}
private void showTimeRangeDialog(Context context, int from, int to) {
TimeRangeDialog dialog = new TimeRangeDialog(context, from, to);
dialog.setOnDismissListener(v -> {
SleepTimerPreferences.setAutoEnableFrom(dialog.getFrom());
SleepTimerPreferences.setAutoEnableTo(dialog.getTo());
boolean mostOfDay = isSleepTimerConfiguredForMostOfTheDay();
// disable the checkbox if they've selected always
// only change the state if true, don't change it regardless of flag (although we could)
if (mostOfDay && SleepTimerPreferences.getSleepTimerType() == SleepTimerType.EPISODES) {
confirmAlwaysSleepTimerDialog();
} else if (!chAutoEnable.isChecked()) { // if it's not checked, then make sure it's checked in UI too
chAutoEnable.setChecked(true);
}
updateAutoEnableText();
});
dialog.show();
@ -206,7 +457,14 @@ public class SleepTimerDialog extends DialogFragment {
public void timerUpdated(SleepTimerUpdatedEvent event) {
timeDisplay.setVisibility(event.isOver() || event.isCancelled() ? View.GONE : View.VISIBLE);
timeSetup.setVisibility(event.isOver() || event.isCancelled() ? View.VISIBLE : View.GONE);
time.setText(Converter.getDurationStringLong((int) event.getTimeLeft()));
sleepTimerType.setEnabled(event.isOver() || event.isCancelled());
if (SleepTimerPreferences.getSleepTimerType() == SleepTimerType.EPISODES) {
time.setText(getResources().getQuantityString(R.plurals.num_episodes,
(int) event.getDisplayTimeLeft(), (int) event.getDisplayTimeLeft()));
} else {
time.setText(Converter.getDurationStringLong((int) event.getDisplayTimeLeft()));
}
}
private void closeKeyboard(View content) {

View File

@ -306,6 +306,10 @@ public class AudioPlayerFragment extends Fragment implements
@Subscribe(threadMode = ThreadMode.MAIN)
@SuppressWarnings("unused")
public void sleepTimerUpdate(SleepTimerUpdatedEvent event) {
if (event.isOver()) {
toolbar.getMenu().findItem(R.id.set_sleeptimer_item).setVisible(true);
toolbar.getMenu().findItem(R.id.disable_sleeptimer_item).setVisible(false);
}
if (event.isCancelled() || event.wasJustEnabled() || event.isOver()) {
AudioPlayerFragment.this.loadMediaInfo(false);
}

View File

@ -32,6 +32,7 @@ import org.greenrobot.eventbus.ThreadMode;
public class PreferenceActivity extends ToolbarActivity implements SearchPreferenceResultListener {
private static final String FRAGMENT_TAG = "tag_preferences";
public static final String OPEN_AUTO_DOWNLOAD_SETTINGS = "OpenAutoDownloadSettings";
public static final String OPEN_PLAYBACK_SETTINGS = "OpenPlaybackSettings";
private SettingsActivityBinding binding;
@Override
@ -55,6 +56,9 @@ public class PreferenceActivity extends ToolbarActivity implements SearchPrefere
if (intent.getBooleanExtra(OPEN_AUTO_DOWNLOAD_SETTINGS, false)) {
openScreen(R.xml.preferences_autodownload);
}
if (intent.getBooleanExtra(OPEN_PLAYBACK_SETTINGS, false)) {
openScreen(R.xml.preferences_playback);
}
}
private PreferenceFragmentCompat getPreferenceScreen(int screen) {

View File

@ -35,7 +35,8 @@
android:inputType="number"
android:maxLength="3" />
<TextView
<Spinner
android:id="@+id/sleepTimerType"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/time_minutes"
@ -44,6 +45,17 @@
</LinearLayout>
<TextView
android:id="@+id/sleepTimerHintText"
android:layout_width="match_parent"
android:layout_height="0dp"
android:visibility="visible"
android:layout_weight="1"
android:selectAllOnFocus="true"
android:layout_margin="8dp"
android:ems="2"
android:text="@string/multiple_sleep_episodes_while_continuous_playback_disabled" />
<Button
android:id="@+id/setSleeptimerButton"
android:layout_width="match_parent"
@ -163,6 +175,13 @@
</LinearLayout>
<Button
android:id="@+id/playbackPreferencesButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="visible"
android:text="@string/settings_label" />
</LinearLayout>
</LinearLayout>

View File

@ -1,38 +1,45 @@
package de.danoeh.antennapod.event.playback;
import de.danoeh.antennapod.model.playback.TimerValue;
public class SleepTimerUpdatedEvent {
private static final long CANCELLED = Long.MAX_VALUE;
private final long timeLeft;
private final TimerValue timerValue;
private SleepTimerUpdatedEvent(long timeLeft) {
this.timeLeft = timeLeft;
private SleepTimerUpdatedEvent(final TimerValue timerValue) {
this.timerValue = timerValue;
}
public static SleepTimerUpdatedEvent justEnabled(long timeLeft) {
return new SleepTimerUpdatedEvent(-timeLeft);
public static SleepTimerUpdatedEvent justEnabled(final TimerValue timer) {
return new SleepTimerUpdatedEvent(new TimerValue(timer.getDisplayValue(), -timer.getMillisValue()));
}
public static SleepTimerUpdatedEvent updated(long timeLeft) {
return new SleepTimerUpdatedEvent(Math.max(0, timeLeft));
public static SleepTimerUpdatedEvent updated(final TimerValue timer) {
return new SleepTimerUpdatedEvent(
new TimerValue(Math.max(timer.getDisplayValue(), 0), Math.max(0, timer.getMillisValue())));
}
public static SleepTimerUpdatedEvent cancelled() {
return new SleepTimerUpdatedEvent(CANCELLED);
return new SleepTimerUpdatedEvent(new TimerValue(CANCELLED, CANCELLED));
}
public long getTimeLeft() {
return Math.abs(timeLeft);
public long getMillisTimeLeft() {
return Math.abs(timerValue.getMillisValue());
}
public long getDisplayTimeLeft() {
return Math.abs(timerValue.getDisplayValue());
}
public boolean isOver() {
return timeLeft == 0;
return timerValue.getMillisValue() == 0;
}
public boolean wasJustEnabled() {
return timeLeft < 0;
return timerValue.getMillisValue() < 0;
}
public boolean isCancelled() {
return timeLeft == CANCELLED;
return timerValue.getMillisValue() == CANCELLED;
}
}

View File

@ -0,0 +1,19 @@
package de.danoeh.antennapod.model.playback;
public class TimerValue {
private final long displayValue; // Value shown to user (milliseconds or number of episodes)
private final long millisValue;
public TimerValue(long displayValue, long millisValue) {
this.displayValue = displayValue;
this.millisValue = millisValue;
}
public long getDisplayValue() {
return displayValue;
}
public long getMillisValue() {
return millisValue;
}
}

View File

@ -338,6 +338,10 @@ public abstract class PlaybackServiceMediaPlayer {
void shouldStop();
void episodeFinishedPlayback();
boolean shouldContinueToNextEpisode();
void onMediaChanged(boolean reloadUI);
void onPostPlayback(@NonNull Playable media, boolean ended, boolean skipped, boolean playingNext);

View File

@ -14,6 +14,7 @@ import android.view.SurfaceHolder;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import de.danoeh.antennapod.playback.service.internal.PlayableUtils;
import de.danoeh.antennapod.model.playback.TimerValue;
import de.danoeh.antennapod.storage.database.DBReader;
import de.danoeh.antennapod.storage.database.DBWriter;
import de.danoeh.antennapod.event.playback.PlaybackPositionEvent;
@ -355,18 +356,18 @@ public abstract class PlaybackController {
}
}
public long getSleepTimerTimeLeft() {
public TimerValue getSleepTimerTimeLeft() {
if (playbackService != null) {
return playbackService.getSleepTimerTimeLeft();
} else {
return Playable.INVALID_TIME;
return new TimerValue(Playable.INVALID_TIME, Playable.INVALID_TIME);
}
}
public void extendSleepTimer(long extendTime) {
long timeLeft = getSleepTimerTimeLeft();
if (playbackService != null && timeLeft != Playable.INVALID_TIME) {
setSleepTimer(timeLeft + extendTime);
TimerValue timeLeft = getSleepTimerTimeLeft();
if (playbackService != null && timeLeft.getMillisValue() != Playable.INVALID_TIME) {
setSleepTimer(timeLeft.getDisplayValue() + extendTime);
}
}

View File

@ -57,13 +57,16 @@ import androidx.media.utils.MediaConstants;
import de.danoeh.antennapod.event.PlayerStatusEvent;
import de.danoeh.antennapod.net.sync.serviceinterface.SynchronizationQueue;
import de.danoeh.antennapod.playback.service.internal.ClockSleepTimer;
import de.danoeh.antennapod.playback.service.internal.EpisodeSleepTimer;
import de.danoeh.antennapod.playback.service.internal.LocalPSMP;
import de.danoeh.antennapod.playback.service.internal.PlayableUtils;
import de.danoeh.antennapod.playback.service.internal.PlaybackServiceNotificationBuilder;
import de.danoeh.antennapod.playback.service.internal.PlaybackServiceStateManager;
import de.danoeh.antennapod.playback.service.internal.PlaybackServiceTaskManager;
import de.danoeh.antennapod.playback.service.internal.PlaybackVolumeUpdater;
import de.danoeh.antennapod.model.playback.TimerValue;
import de.danoeh.antennapod.playback.service.internal.WearMediaSession;
import de.danoeh.antennapod.storage.preferences.SleepTimerType;
import de.danoeh.antennapod.ui.notifications.NotificationUtils;
import de.danoeh.antennapod.ui.widget.WidgetUpdater;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
@ -341,6 +344,7 @@ public class PlaybackService extends MediaBrowserServiceCompat {
unregisterReceiver(audioBecomingNoisy);
mediaPlayer.shutdown();
taskManager.shutdown();
disableSleepTimer();
EventBus.getDefault().unregister(this);
}
@ -900,7 +904,7 @@ public class PlaybackService extends MediaBrowserServiceCompat {
if (newInfo.getOldPlayerStatus() != null && newInfo.getOldPlayerStatus() != PlayerStatus.SEEKING
&& SleepTimerPreferences.autoEnable() && autoEnableByTime && !sleepTimerActive()) {
setSleepTimer(SleepTimerPreferences.timerMillis());
setSleepTimer(SleepTimerPreferences.timerMillisOrEpisodes());
EventBus.getDefault().post(new MessageEvent(getString(R.string.sleep_timer_enabled_label),
(ctx) -> disableSleepTimer(), getString(R.string.undo)));
}
@ -984,6 +988,16 @@ public class PlaybackService extends MediaBrowserServiceCompat {
return PlaybackService.this.getNextInQueue(currentMedia);
}
@Override
public boolean shouldContinueToNextEpisode() {
return PlaybackService.this.shouldContinueToNextEpisode();
}
@Override
public void episodeFinishedPlayback() {
PlaybackService.this.episodeFinishedPlayback();
}
@Nullable
@Override
public Playable findMedia(@NonNull String url) {
@ -1038,9 +1052,9 @@ public class PlaybackService extends MediaBrowserServiceCompat {
int newPosition = mediaPlayer.getPosition() - (int) SleepTimer.NOTIFICATION_THRESHOLD / 2;
newPosition = Math.max(newPosition, 0);
seekTo(newPosition);
} else if (event.getTimeLeft() < SleepTimer.NOTIFICATION_THRESHOLD) {
} else if (event.getMillisTimeLeft() < SleepTimer.NOTIFICATION_THRESHOLD) {
final float[] multiplicators = {0.1f, 0.2f, 0.3f, 0.3f, 0.3f, 0.4f, 0.4f, 0.4f, 0.6f, 0.8f};
float multiplicator = multiplicators[Math.max(0, (int) event.getTimeLeft() / 1000)];
float multiplicator = multiplicators[Math.max(0, (int) event.getMillisTimeLeft() / 1000)];
Log.d(TAG, "onSleepTimerAlmostExpired: " + multiplicator);
mediaPlayer.setVolume(multiplicator, multiplicator);
} else if (event.isCancelled()) {
@ -1076,7 +1090,11 @@ public class PlaybackService extends MediaBrowserServiceCompat {
return null;
}
if (!UserPreferences.isFollowQueue()) {
// continue playback if user has enabled continuous playback
// OR they enabled an episode sleep timer and there are still episodes left to play
final boolean continuousPlayback = UserPreferences.isFollowQueue() && shouldContinueToNextEpisode();
if (!continuousPlayback) {
Log.d(TAG, "getNextInQueue(), but follow queue is not enabled.");
PlaybackPreferences.writeMediaPlaying(nextItem.getMedia());
updateNotificationAndMediaSession(nextItem.getMedia());
@ -1095,6 +1113,23 @@ public class PlaybackService extends MediaBrowserServiceCompat {
return nextItem.getMedia();
}
private void episodeFinishedPlayback() {
if (sleepTimer != null) {
sleepTimer.episodeFinishedPlayback();
}
}
/**
* Tells if we should continue to next episode
* @return True if we can proceed to the next episode (might be blocked by other things), or not
*/
private boolean shouldContinueToNextEpisode() {
if (sleepTimer != null) {
return sleepTimer.shouldContinueToNextEpisode();
}
return true; // always allow when no sleep timer is active
}
/**
* Set of instructions to be performed when playback ends.
*/
@ -1104,6 +1139,7 @@ public class PlaybackService extends MediaBrowserServiceCompat {
if (stopPlaying) {
taskManager.cancelPositionSaver();
cancelPositionObserver();
disableSleepTimer();
if (!isCasting) {
stateManager.stopForeground(true);
stateManager.stopService();
@ -1218,7 +1254,8 @@ public class PlaybackService extends MediaBrowserServiceCompat {
if (sleepTimerActive()) {
sleepTimer.updateRemainingTime(waitingTime);
} else {
sleepTimer = new ClockSleepTimer(getApplicationContext());
sleepTimer = SleepTimerPreferences.getSleepTimerType() == SleepTimerType.CLOCK ?
new ClockSleepTimer(getApplicationContext()) : new EpisodeSleepTimer(getApplicationContext());
sleepTimer.start(waitingTime);
}
}
@ -1512,11 +1549,11 @@ public class PlaybackService extends MediaBrowserServiceCompat {
return sleepTimer != null && sleepTimer.isActive();
}
public long getSleepTimerTimeLeft() {
public TimerValue getSleepTimerTimeLeft() {
if (sleepTimerActive()) {
return sleepTimer.getTimeLeft();
} else {
return 0;
return new TimerValue(0, 0);
}
}
@ -2037,7 +2074,7 @@ public class PlaybackService extends MediaBrowserServiceCompat {
if (sleepTimerActive()) {
disableSleepTimer();
} else {
setSleepTimer(SleepTimerPreferences.timerMillis());
setSleepTimer(SleepTimerPreferences.timerMillisOrEpisodes());
}
}
}

View File

@ -13,6 +13,7 @@ import org.greenrobot.eventbus.ThreadMode;
import de.danoeh.antennapod.event.playback.PlaybackPositionEvent;
import de.danoeh.antennapod.event.playback.SleepTimerUpdatedEvent;
import de.danoeh.antennapod.model.playback.TimerValue;
import de.danoeh.antennapod.storage.preferences.SleepTimerPreferences;
public class ClockSleepTimer implements SleepTimer {
@ -42,12 +43,13 @@ public class ClockSleepTimer implements SleepTimer {
}
timeLeft -= timeSinceLastTick;
EventBus.getDefault().postSticky(SleepTimerUpdatedEvent.updated(timeLeft));
final TimerValue left = getTimeLeft();
EventBus.getDefault().postSticky(SleepTimerUpdatedEvent.updated(left));
if (timeLeft < NOTIFICATION_THRESHOLD) {
notifyAboutExpiry();
}
if (timeLeft <= 0) {
Log.d(TAG, "Sleep timer expired");
Log.d(TAG, "Clock Sleep timer expired");
stop();
}
}
@ -95,11 +97,11 @@ public class ClockSleepTimer implements SleepTimer {
// make sure we've registered for events first
EventBus.getDefault().register(this);
final long left = getTimeLeft();
final TimerValue left = getTimeLeft();
EventBus.getDefault().post(SleepTimerUpdatedEvent.justEnabled(left));
lastTick = System.currentTimeMillis();
EventBus.getDefault().postSticky(SleepTimerUpdatedEvent.updated(timeLeft));
EventBus.getDefault().postSticky(SleepTimerUpdatedEvent.updated(left));
isRunning = true;
}
@ -121,8 +123,8 @@ public class ClockSleepTimer implements SleepTimer {
}
@Override
public long getTimeLeft() {
return timeLeft;
public TimerValue getTimeLeft() {
return new TimerValue(timeLeft, timeLeft);
}
@Override
@ -134,4 +136,19 @@ public class ClockSleepTimer implements SleepTimer {
public void reset() {
updateRemainingTime(initialWaitingTime);
}
@Override
public boolean isEndingThisEpisode(long episodeRemainingMillis) {
return episodeRemainingMillis >= getTimeLeft().getMillisValue();
}
@Override
public boolean shouldContinueToNextEpisode() {
return getTimeLeft().getMillisValue() > 0;
}
@Override
public void episodeFinishedPlayback() {
//no-op
}
}

View File

@ -0,0 +1,67 @@
package de.danoeh.antennapod.playback.service.internal;
import android.content.Context;
import org.greenrobot.eventbus.EventBus;
import java.util.concurrent.TimeUnit;
import de.danoeh.antennapod.event.playback.PlaybackPositionEvent;
import de.danoeh.antennapod.event.playback.SleepTimerUpdatedEvent;
import de.danoeh.antennapod.model.playback.TimerValue;
public class EpisodeSleepTimer extends ClockSleepTimer {
public EpisodeSleepTimer(final Context context) {
super(context);
}
@Override
public boolean isEndingThisEpisode(long episodeRemainingMillis) {
return getTimeLeft().getDisplayValue() == 1;
}
@Override
public TimerValue getTimeLeft() {
TimerValue x = super.getTimeLeft();
return new TimerValue(x.getDisplayValue(), TimeUnit.DAYS.toMillis(x.getDisplayValue()));
}
@Override
public void playbackPositionUpdate(PlaybackPositionEvent playbackPositionEvent) {
long currentEpisodeTimeLeft = playbackPositionEvent.getDuration() - playbackPositionEvent.getPosition();
final TimerValue current = getTimeLeft();
if (isEndingThisEpisode(playbackPositionEvent.getPosition())) {
// if we're ending this episode send the "correct" remaining time
// this ensures that the last 10 seconds the playback volume will be reduced
EventBus.getDefault().post(SleepTimerUpdatedEvent.updated(new TimerValue(
current.getDisplayValue(), currentEpisodeTimeLeft)));
if (currentEpisodeTimeLeft < NOTIFICATION_THRESHOLD) {
notifyAboutExpiry();
}
} else {
// if we have more than 1 episode left then just report the current values
EventBus.getDefault().post(SleepTimerUpdatedEvent.updated(current));
}
}
@Override
public void episodeFinishedPlayback() {
// episode has finished, decrease the number of episodes left
updateRemainingTime(getTimeLeft().getDisplayValue() - 1);
}
@Override
public boolean shouldContinueToNextEpisode() {
boolean cont = getTimeLeft().getDisplayValue() > 0; // number of episodes left
// stop ourselves too if we're blocking playback
if (!cont) {
stop();
}
return cont;
}
}

View File

@ -678,9 +678,11 @@ public class LocalPSMP extends PlaybackServiceMediaPlayer {
@Override
protected void endPlayback(final boolean hasEnded, final boolean wasSkipped,
final boolean shouldContinue, final boolean toStoppedState) {
boolean shouldContinue, final boolean toStoppedState) {
releaseWifiLockIfNecessary();
callback.episodeFinishedPlayback(); // notify that the current episode just finished
boolean isPlaying = playerStatus == PlayerStatus.PLAYING;
// we're relying on the position stored in the Playable object for post-playback processing
@ -700,6 +702,9 @@ public class LocalPSMP extends PlaybackServiceMediaPlayer {
final Playable currentMedia = media;
Playable nextMedia = null;
// we should continue to next episode if we were told to continue and we're allowed to (by sleep timer)
shouldContinue &= callback.shouldContinueToNextEpisode();
if (shouldContinue) {
// Load next episode if previous episode was in the queue and if there
// is an episode in the queue left.

View File

@ -1,13 +1,15 @@
package de.danoeh.antennapod.playback.service.internal;
import de.danoeh.antennapod.model.playback.TimerValue;
public interface SleepTimer {
long NOTIFICATION_THRESHOLD = 10000;
/**
* @return Returns time left for this timer, in millis
* @return Returns time left for this sleep timer, both display value and in milis
*/
long getTimeLeft();
TimerValue getTimeLeft();
/**
* Starts the sleep timer.
@ -35,4 +37,19 @@ public interface SleepTimer {
* @return True if sleep timer is active, false otherwise
*/
boolean isActive();
/**
* @param episodeRemainingMillis Remaining milliseconds of current episode
* @return Returns true if the sleep timer will terminate sometime during this episode, false otherwise
*/
boolean isEndingThisEpisode(long episodeRemainingMillis);
/**
* Called when sleep timer is asked if playback is allowed to proceed to next episode.
* Should take into account the time left, episodes left, etc.
* @return True if playback is allowed to continue to next episode, false otherwise
*/
boolean shouldContinueToNextEpisode();
void episodeFinishedPlayback();
}

View File

@ -198,6 +198,23 @@ public final class DBReader {
}
}
/**
* Gets the remaining queue size, given a current item, including the current item.
* If the current item is not found it will return 0.
*/
public static int getRemainingQueueSize(long existingId) {
final LongList wholeQueue = getQueueIDList();
// now try to find the id
for (int i = 0; i < wholeQueue.size(); ++i) {
if (wholeQueue.get(i) == existingId) {
return wholeQueue.size() - i; // return however many are left, including us
}
}
return 0;
}
/**
* Loads a list of the FeedItems in the queue. If the FeedItems of the queue are not used directly, consider using
* {@link #getQueueIDList()} instead.

View File

@ -15,13 +15,16 @@ public class SleepTimerPreferences {
public static final String PREF_NAME = "SleepTimerDialog";
private static final String PREF_VALUE = "LastValue";
private static final String PREF_TIMER_TYPE = "sleepTimerType";
private static final String PREF_VIBRATE = "Vibrate";
private static final String PREF_SHAKE_TO_RESET = "ShakeToReset";
private static final String PREF_AUTO_ENABLE = "AutoEnable";
private static final String PREF_AUTO_ENABLE_FROM = "AutoEnableFrom";
private static final String PREF_AUTO_ENABLE_TO = "AutoEnableTo";
private static final String DEFAULT_LAST_TIMER = "15";
public static final String DEFAULT_SLEEP_TIMER_MINUTES = "15";
public static final String DEFAULT_SLEEP_TIMER_EPISODES = "1";
private static final int DEFAULT_TIMER_TYPE = 0;
private static final int DEFAULT_AUTO_ENABLE_FROM = 22;
private static final int DEFAULT_AUTO_ENABLE_TO = 6;
@ -42,12 +45,22 @@ public class SleepTimerPreferences {
}
public static String lastTimerValue() {
return prefs.getString(PREF_VALUE, DEFAULT_LAST_TIMER);
return prefs.getString(PREF_VALUE, DEFAULT_SLEEP_TIMER_MINUTES);
}
public static long timerMillis() {
long value = Long.parseLong(lastTimerValue());
return TimeUnit.MINUTES.toMillis(value);
public static long timerMillisOrEpisodes() {
return switch (getSleepTimerType()) {
case CLOCK -> TimeUnit.MINUTES.toMillis(Long.parseLong(lastTimerValue()));
case EPISODES -> Long.parseLong(SleepTimerPreferences.lastTimerValue());
};
}
public static SleepTimerType getSleepTimerType() {
return SleepTimerType.fromIndex(prefs.getInt(PREF_TIMER_TYPE, DEFAULT_TIMER_TYPE));
}
public static void setSleepTimerType(SleepTimerType newType) {
prefs.edit().putInt(PREF_TIMER_TYPE, newType.index).apply();
}
public static void setVibrate(boolean vibrate) {
@ -90,6 +103,16 @@ public class SleepTimerPreferences {
return prefs.getInt(PREF_AUTO_ENABLE_TO, DEFAULT_AUTO_ENABLE_TO);
}
public static int autoEnableDuration() {
final int from = SleepTimerPreferences.autoEnableFrom();
final int to = SleepTimerPreferences.autoEnableTo();
if (from >= to) {
return 24 - (from - to);
} else {
return to - from;
}
}
public static boolean isInTimeRange(int from, int to, int current) {
// Range covers one day
if (from < to) {

View File

@ -0,0 +1,21 @@
package de.danoeh.antennapod.storage.preferences;
public enum SleepTimerType {
CLOCK(0),
EPISODES(1);
public final int index;
SleepTimerType(int index) {
this.index = index;
}
public static SleepTimerType fromIndex(int index) {
for (SleepTimerType stt : values()) {
if (stt.index == index) {
return stt;
}
}
return SleepTimerType.EPISODES;
}
}

View File

@ -9,7 +9,6 @@ import android.view.KeyEvent;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.core.app.NotificationCompat;
import androidx.preference.PreferenceManager;
@ -400,7 +399,6 @@ public abstract class UserPreferences {
/**
* Set to true to enable Continuous Playback
*/
@VisibleForTesting
public static void setFollowQueue(boolean value) {
prefs.edit().putBoolean(UserPreferences.PREF_FOLLOW_QUEUE, value).apply();
}

View File

@ -640,9 +640,23 @@
<string name="set_sleeptimer_label">Set sleep timer</string>
<string name="disable_sleeptimer_label">Disable sleep timer</string>
<string name="extend_sleep_timer_label">+%d min</string>
<string name="sleep_timer_episodes_label">episodes</string>
<string name="sleep_timer_without_continuous_playback">Disable continuous playback</string>
<string name="sleep_timer_without_continuous_playback_change_hours">Change hours</string>
<string name="sleep_timer_without_continuous_playback_message">Instead of always automatically enabling the sleep timer with one episode, please instead disable \"continuous playback\" in the settings.</string>
<string name="sleep_timer_always">Always</string>
<string name="sleep_timer_label">Sleep timer</string>
<string name="time_dialog_invalid_input">Invalid input, time has to be an integer</string>
<plurals name="timer_exceeds_remaining_time_while_continuous_playback_disabled">
<item quantity="one">Continuous playback is disabled and the current episode has only %1$d minute left</item>
<item quantity="other">Continuous playback is disabled and the current episode has only %1$d minutes left</item>
</plurals>
<string name="multiple_sleep_episodes_while_continuous_playback_disabled">Continuous playback is disabled in the settings. Always stopping at the end of the episode.</string>
<plurals name="episodes_sleep_timer_exceeds_queue">
<item quantity="zero">There are %1$d episodes left in your queue</item>
<item quantity="one">There is only %1$d episode left in your queue</item>
<item quantity="other">There are only %1$d episodes left in your queue</item>
</plurals>
<string name="shake_to_reset_label">Shake to reset</string>
<string name="timer_vibration_label">Vibrate shortly before end</string>
<string name="time_seconds">seconds</string>