diff options
Diffstat (limited to 'ui')
19 files changed, 1037 insertions, 3 deletions
diff --git a/ui/common/src/main/res/values/dimens.xml b/ui/common/src/main/res/values/dimens.xml index 68d0e59ab..5cfcd68b6 100644 --- a/ui/common/src/main/res/values/dimens.xml +++ b/ui/common/src/main/res/values/dimens.xml @@ -1,7 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <dimen name="widget_margin">0dp</dimen> - <dimen name="widget_inner_radius">4dp</dimen> <dimen name="external_player_height">64dp</dimen> <dimen name="text_size_micro">12sp</dimen> <dimen name="text_size_small">14sp</dimen> diff --git a/ui/echo/build.gradle b/ui/echo/build.gradle index 0175891a8..820ccb208 100644 --- a/ui/echo/build.gradle +++ b/ui/echo/build.gradle @@ -18,6 +18,7 @@ dependencies { implementation project(':storage:database') implementation project(":storage:preferences") implementation project(':ui:common') + implementation project(':ui:episodes') implementation project(':ui:glide') annotationProcessor "androidx.annotation:annotation:$annotationVersion" diff --git a/ui/echo/src/main/java/de/danoeh/antennapod/ui/echo/EchoActivity.java b/ui/echo/src/main/java/de/danoeh/antennapod/ui/echo/EchoActivity.java index da5121a98..bfe5fbf98 100644 --- a/ui/echo/src/main/java/de/danoeh/antennapod/ui/echo/EchoActivity.java +++ b/ui/echo/src/main/java/de/danoeh/antennapod/ui/echo/EchoActivity.java @@ -23,7 +23,6 @@ import androidx.core.view.WindowCompat; import com.bumptech.glide.Glide; import com.bumptech.glide.load.resource.bitmap.RoundedCorners; import com.bumptech.glide.request.RequestOptions; -import de.danoeh.antennapod.core.feed.util.PlaybackSpeedUtils; import de.danoeh.antennapod.model.feed.FeedItem; import de.danoeh.antennapod.storage.database.DBReader; import de.danoeh.antennapod.storage.database.StatisticsItem; @@ -36,6 +35,7 @@ import de.danoeh.antennapod.ui.echo.screens.RotatingSquaresScreen; import de.danoeh.antennapod.ui.echo.screens.StripesScreen; import de.danoeh.antennapod.ui.echo.screens.WaveformScreen; import de.danoeh.antennapod.ui.echo.screens.WavesScreen; +import de.danoeh.antennapod.ui.episodes.PlaybackSpeedUtils; import io.reactivex.Flowable; import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; diff --git a/ui/episodes/README.md b/ui/episodes/README.md new file mode 100644 index 000000000..2ecd6faef --- /dev/null +++ b/ui/episodes/README.md @@ -0,0 +1,3 @@ +# :ui:episodes + +Common classes that are needed everywhere we display information about episodes. diff --git a/ui/episodes/build.gradle b/ui/episodes/build.gradle new file mode 100644 index 000000000..9dfcd4903 --- /dev/null +++ b/ui/episodes/build.gradle @@ -0,0 +1,19 @@ +plugins { + id("com.android.library") +} +apply from: "../../common.gradle" + +android { + namespace "de.danoeh.antennapod.ui.episodes" +} + +dependencies { + implementation project(":model") + implementation project(":storage:preferences") + implementation project(":ui:common") + + annotationProcessor "androidx.annotation:annotation:$annotationVersion" + implementation "androidx.appcompat:appcompat:$appcompatVersion" + implementation "androidx.core:core:$coreVersion" + implementation "com.google.android.material:material:$googleMaterialVersion" +} diff --git a/ui/episodes/src/main/java/de/danoeh/antennapod/ui/episodes/ImageResourceUtils.java b/ui/episodes/src/main/java/de/danoeh/antennapod/ui/episodes/ImageResourceUtils.java new file mode 100644 index 000000000..396df7b90 --- /dev/null +++ b/ui/episodes/src/main/java/de/danoeh/antennapod/ui/episodes/ImageResourceUtils.java @@ -0,0 +1,66 @@ +package de.danoeh.antennapod.ui.episodes; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import de.danoeh.antennapod.model.feed.FeedItem; +import de.danoeh.antennapod.model.feed.FeedMedia; +import de.danoeh.antennapod.storage.preferences.UserPreferences; +import de.danoeh.antennapod.model.playback.Playable; + +/** + * Utility class to use the appropriate image resource based on {@link UserPreferences}. + */ +public final class ImageResourceUtils { + + private ImageResourceUtils() { + } + + /** + * returns the image location, does prefer the episode cover if available and enabled in settings. + */ + @Nullable + public static String getEpisodeListImageLocation(@NonNull Playable playable) { + if (UserPreferences.getUseEpisodeCoverSetting()) { + return playable.getImageLocation(); + } else { + return getFallbackImageLocation(playable); + } + } + + /** + * returns the image location, does prefer the episode cover if available and enabled in settings. + */ + @Nullable + public static String getEpisodeListImageLocation(@NonNull FeedItem feedItem) { + if (UserPreferences.getUseEpisodeCoverSetting()) { + return feedItem.getImageLocation(); + } else { + return getFallbackImageLocation(feedItem); + } + } + + @Nullable + public static String getFallbackImageLocation(@NonNull Playable playable) { + if (playable instanceof FeedMedia) { + FeedMedia media = (FeedMedia) playable; + FeedItem item = media.getItem(); + if (item != null && item.getFeed() != null) { + return item.getFeed().getImageUrl(); + } else { + return null; + } + } else { + return playable.getImageLocation(); + } + } + + @Nullable + public static String getFallbackImageLocation(@NonNull FeedItem feedItem) { + if (feedItem.getFeed() != null) { + return feedItem.getFeed().getImageUrl(); + } else { + return null; + } + } +} diff --git a/ui/episodes/src/main/java/de/danoeh/antennapod/ui/episodes/PlaybackSpeedUtils.java b/ui/episodes/src/main/java/de/danoeh/antennapod/ui/episodes/PlaybackSpeedUtils.java new file mode 100644 index 000000000..e3c5ab672 --- /dev/null +++ b/ui/episodes/src/main/java/de/danoeh/antennapod/ui/episodes/PlaybackSpeedUtils.java @@ -0,0 +1,60 @@ +package de.danoeh.antennapod.ui.episodes; + +import de.danoeh.antennapod.model.feed.Feed; +import de.danoeh.antennapod.model.feed.FeedMedia; +import de.danoeh.antennapod.model.feed.FeedPreferences; +import de.danoeh.antennapod.storage.preferences.PlaybackPreferences; +import de.danoeh.antennapod.storage.preferences.UserPreferences; +import de.danoeh.antennapod.model.playback.Playable; + +/** + * Utility class to use the appropriate playback speed based on {@link PlaybackPreferences} + */ +public abstract class PlaybackSpeedUtils { + /** + * Returns the currently configured playback speed for the specified media. + */ + public static float getCurrentPlaybackSpeed(Playable media) { + float playbackSpeed = FeedPreferences.SPEED_USE_GLOBAL; + if (media instanceof FeedMedia) { + FeedMedia feedMedia = (FeedMedia) media; + if (PlaybackPreferences.getCurrentlyPlayingFeedMediaId() == feedMedia.getId()) { + playbackSpeed = PlaybackPreferences.getCurrentlyPlayingTemporaryPlaybackSpeed(); + } + if (playbackSpeed == FeedPreferences.SPEED_USE_GLOBAL && feedMedia.getItem() != null) { + Feed feed = feedMedia.getItem().getFeed(); + if (feed != null && feed.getPreferences() != null) { + playbackSpeed = feed.getPreferences().getFeedPlaybackSpeed(); + } + } + } + if (playbackSpeed == FeedPreferences.SPEED_USE_GLOBAL) { + playbackSpeed = UserPreferences.getPlaybackSpeed(); + } + return playbackSpeed; + } + + /** + * Returns the currently configured skip silence for the specified media. + */ + public static FeedPreferences.SkipSilence getCurrentSkipSilencePreference(Playable media) { + FeedPreferences.SkipSilence skipSilence = FeedPreferences.SkipSilence.GLOBAL; + if (media instanceof FeedMedia) { + FeedMedia feedMedia = (FeedMedia) media; + if (PlaybackPreferences.getCurrentlyPlayingFeedMediaId() == feedMedia.getId()) { + skipSilence = PlaybackPreferences.getCurrentlyPlayingTemporarySkipSilence(); + } + if (skipSilence == FeedPreferences.SkipSilence.GLOBAL && feedMedia.getItem() != null) { + Feed feed = feedMedia.getItem().getFeed(); + if (feed != null && feed.getPreferences() != null) { + skipSilence = feed.getPreferences().getFeedSkipSilence(); + } + } + } + if (skipSilence == FeedPreferences.SkipSilence.GLOBAL) { + skipSilence = UserPreferences.isSkipSilence() + ? FeedPreferences.SkipSilence.AGGRESSIVE : FeedPreferences.SkipSilence.OFF; + } + return skipSilence; + } +} diff --git a/ui/episodes/src/main/java/de/danoeh/antennapod/ui/episodes/TimeSpeedConverter.java b/ui/episodes/src/main/java/de/danoeh/antennapod/ui/episodes/TimeSpeedConverter.java new file mode 100644 index 000000000..450eca967 --- /dev/null +++ b/ui/episodes/src/main/java/de/danoeh/antennapod/ui/episodes/TimeSpeedConverter.java @@ -0,0 +1,23 @@ +package de.danoeh.antennapod.ui.episodes; + +import de.danoeh.antennapod.storage.preferences.UserPreferences; + +public class TimeSpeedConverter { + private final float speed; + + public TimeSpeedConverter(float speed) { + this.speed = speed; + } + + /** Convert millisecond according to the current playback speed + * @param time time to convert + * @return converted time (can be < 0 if time is < 0) + */ + public int convert(int time) { + boolean timeRespectsSpeed = UserPreferences.timeRespectsSpeed(); + if (time > 0 && timeRespectsSpeed) { + return (int)(time / speed); + } + return time; + } +} diff --git a/ui/widget/build.gradle b/ui/widget/build.gradle index 2488054a6..03183a323 100644 --- a/ui/widget/build.gradle +++ b/ui/widget/build.gradle @@ -2,6 +2,7 @@ plugins { id("com.android.library") } apply from: "../../common.gradle" +apply from: "../../playFlavor.gradle" android { namespace "de.danoeh.antennapod.ui.widget" @@ -10,10 +11,25 @@ android { vectorDrawables.useSupportLibrary false vectorDrawables.generatedDensities = ["xhdpi"] } + + lint { + disable "IconMissingDensityFolder" + } } dependencies { + implementation project(":model") + implementation project(":playback:base") + implementation project(':storage:preferences') + implementation project(':storage:database') + implementation project(":ui:app-start-intent") implementation project(":ui:common") + implementation project(":ui:episodes") + implementation project(':ui:glide') + implementation project(':ui:i18n') annotationProcessor "androidx.annotation:annotation:$annotationVersion" + implementation "androidx.appcompat:appcompat:$appcompatVersion" + implementation "androidx.work:work-runtime:$workManagerVersion" + implementation "com.github.bumptech.glide:glide:$glideVersion" } diff --git a/ui/widget/src/main/AndroidManifest.xml b/ui/widget/src/main/AndroidManifest.xml new file mode 100644 index 000000000..c0f03b6cc --- /dev/null +++ b/ui/widget/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + + <application> + <activity + android:name=".WidgetConfigActivity" + android:label="@string/widget_settings" + android:exported="true"> + <intent-filter> + <action android:name="android.appwidget.action.APPWIDGET_CONFIGURE"/> + </intent-filter> + </activity> + + <receiver + android:name=".PlayerWidget" + android:exported="true"> + <intent-filter> + <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> + <action android:name="de.danoeh.antennapod.FORCE_WIDGET_UPDATE"/> + <action android:name="de.danoeh.antennapod.STOP_WIDGET_UPDATE"/> + </intent-filter> + <meta-data + android:name="android.appwidget.provider" + android:resource="@xml/player_widget_info"/> + </receiver> + </application> +</manifest> diff --git a/ui/widget/src/main/java/de/danoeh/antennapod/ui/widget/PlayerWidget.java b/ui/widget/src/main/java/de/danoeh/antennapod/ui/widget/PlayerWidget.java new file mode 100644 index 000000000..c548c075a --- /dev/null +++ b/ui/widget/src/main/java/de/danoeh/antennapod/ui/widget/PlayerWidget.java @@ -0,0 +1,98 @@ +package de.danoeh.antennapod.ui.widget; + +import android.appwidget.AppWidgetManager; +import android.appwidget.AppWidgetProvider; +import android.content.ComponentName; +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; +import androidx.work.ExistingWorkPolicy; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkManager; + +import java.util.Arrays; +import java.util.concurrent.TimeUnit; + +public class PlayerWidget extends AppWidgetProvider { + private static final String TAG = "PlayerWidget"; + public static final String PREFS_NAME = "PlayerWidgetPrefs"; + private static final String KEY_WORKAROUND_ENABLED = "WorkaroundEnabled"; + private static final String KEY_ENABLED = "WidgetEnabled"; + public static final String KEY_WIDGET_COLOR = "widget_color"; + public static final String KEY_WIDGET_PLAYBACK_SPEED = "widget_playback_speed"; + public static final String KEY_WIDGET_SKIP = "widget_skip"; + public static final String KEY_WIDGET_FAST_FORWARD = "widget_fast_forward"; + public static final String KEY_WIDGET_REWIND = "widget_rewind"; + public static final int DEFAULT_COLOR = 0xff262C31; + private static final String WORKAROUND_WORK_NAME = "WidgetUpdaterWorkaround"; + + @Override + public void onEnabled(Context context) { + super.onEnabled(context); + Log.d(TAG, "Widget enabled"); + setEnabled(context, true); + WidgetUpdaterWorker.enqueueWork(context); + scheduleWorkaround(context); + } + + @Override + public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { + Log.d(TAG, "onUpdate() called with: " + "context = [" + context + "], appWidgetManager = [" + + appWidgetManager + "], appWidgetIds = [" + Arrays.toString(appWidgetIds) + "]"); + WidgetUpdaterWorker.enqueueWork(context); + + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + if (!prefs.getBoolean(KEY_WORKAROUND_ENABLED, false)) { + scheduleWorkaround(context); + prefs.edit().putBoolean(KEY_WORKAROUND_ENABLED, true).apply(); + } + } + + @Override + public void onDisabled(Context context) { + super.onDisabled(context); + Log.d(TAG, "Widget disabled"); + setEnabled(context, false); + } + + @Override + public void onDeleted(Context context, int[] appWidgetIds) { + Log.d(TAG, "OnDeleted"); + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + for (int appWidgetId : appWidgetIds) { + prefs.edit().remove(KEY_WIDGET_COLOR + appWidgetId).apply(); + prefs.edit().remove(KEY_WIDGET_PLAYBACK_SPEED + appWidgetId).apply(); + prefs.edit().remove(KEY_WIDGET_REWIND + appWidgetId).apply(); + prefs.edit().remove(KEY_WIDGET_FAST_FORWARD + appWidgetId).apply(); + prefs.edit().remove(KEY_WIDGET_SKIP + appWidgetId).apply(); + } + AppWidgetManager manager = AppWidgetManager.getInstance(context); + int[] widgetIds = manager.getAppWidgetIds(new ComponentName(context, PlayerWidget.class)); + if (widgetIds.length == 0) { + prefs.edit().putBoolean(KEY_WORKAROUND_ENABLED, false).apply(); + WorkManager.getInstance(context).cancelUniqueWork(WORKAROUND_WORK_NAME); + } + super.onDeleted(context, appWidgetIds); + } + + private static void scheduleWorkaround(Context context) { + // Enqueueing work enables a BOOT_COMPLETED receiver, which in turn makes Android refresh widgets. + // This creates an endless loop with a flickering widget. + // Workaround: When there is a widget, schedule a dummy task in the far future, so that the receiver stays. + final OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(WidgetUpdaterWorker.class) + .setInitialDelay(100 * 356, TimeUnit.DAYS) + .build(); + WorkManager.getInstance(context) + .enqueueUniqueWork(WORKAROUND_WORK_NAME, ExistingWorkPolicy.REPLACE, workRequest); + } + + public static boolean isEnabled(Context context) { + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + return prefs.getBoolean(KEY_ENABLED, false); + } + + private void setEnabled(Context context, boolean enabled) { + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + prefs.edit().putBoolean(KEY_ENABLED, enabled).apply(); + } +} diff --git a/ui/widget/src/main/java/de/danoeh/antennapod/ui/widget/WidgetConfigActivity.java b/ui/widget/src/main/java/de/danoeh/antennapod/ui/widget/WidgetConfigActivity.java new file mode 100644 index 000000000..83fd308ba --- /dev/null +++ b/ui/widget/src/main/java/de/danoeh/antennapod/ui/widget/WidgetConfigActivity.java @@ -0,0 +1,145 @@ +package de.danoeh.antennapod.ui.widget; + +import android.app.Activity; +import android.appwidget.AppWidgetManager; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.graphics.Color; +import android.os.Build; +import android.os.Bundle; +import android.view.View; +import android.widget.CheckBox; +import android.widget.SeekBar; +import android.widget.TextView; +import androidx.appcompat.app.AppCompatActivity; +import de.danoeh.antennapod.ui.common.ThemeSwitcher; + +import java.util.Locale; + +public class WidgetConfigActivity extends AppCompatActivity { + private int appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID; + + private SeekBar opacitySeekBar; + private TextView opacityTextView; + private View widgetPreview; + private CheckBox ckPlaybackSpeed; + private CheckBox ckRewind; + private CheckBox ckFastForward; + private CheckBox ckSkip; + + @Override + protected void onCreate(Bundle savedInstanceState) { + setTheme(ThemeSwitcher.getTheme(this)); + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_widget_config); + + Intent configIntent = getIntent(); + Bundle extras = configIntent.getExtras(); + if (extras != null) { + appWidgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID); + } + + Intent resultValue = new Intent(); + resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); + setResult(Activity.RESULT_CANCELED, resultValue); + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish(); + } + + opacityTextView = findViewById(R.id.widget_opacity_textView); + opacitySeekBar = findViewById(R.id.widget_opacity_seekBar); + widgetPreview = findViewById(R.id.widgetLayout); + findViewById(R.id.butConfirm).setOnClickListener(v -> confirmCreateWidget()); + opacitySeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + + @Override + public void onProgressChanged(SeekBar seekBar, int i, boolean b) { + opacityTextView.setText(String.format(Locale.getDefault(), "%d%%", seekBar.getProgress())); + int color = getColorWithAlpha(PlayerWidget.DEFAULT_COLOR, opacitySeekBar.getProgress()); + widgetPreview.setBackgroundColor(color); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + } + + }); + + widgetPreview.findViewById(R.id.txtNoPlaying).setVisibility(View.GONE); + TextView title = widgetPreview.findViewById(R.id.txtvTitle); + title.setVisibility(View.VISIBLE); + title.setText(R.string.app_name); + TextView progress = widgetPreview.findViewById(R.id.txtvProgress); + progress.setVisibility(View.VISIBLE); + progress.setText(R.string.position_default_label); + + ckPlaybackSpeed = findViewById(R.id.ckPlaybackSpeed); + ckPlaybackSpeed.setOnClickListener(v -> displayPreviewPanel()); + ckRewind = findViewById(R.id.ckRewind); + ckRewind.setOnClickListener(v -> displayPreviewPanel()); + ckFastForward = findViewById(R.id.ckFastForward); + ckFastForward.setOnClickListener(v -> displayPreviewPanel()); + ckSkip = findViewById(R.id.ckSkip); + ckSkip.setOnClickListener(v -> displayPreviewPanel()); + + setInitialState(); + } + + private void setInitialState() { + SharedPreferences prefs = getSharedPreferences(PlayerWidget.PREFS_NAME, Context.MODE_PRIVATE); + ckPlaybackSpeed.setChecked(prefs.getBoolean(PlayerWidget.KEY_WIDGET_PLAYBACK_SPEED + appWidgetId, false)); + ckRewind.setChecked(prefs.getBoolean(PlayerWidget.KEY_WIDGET_REWIND + appWidgetId, false)); + ckFastForward.setChecked(prefs.getBoolean(PlayerWidget.KEY_WIDGET_FAST_FORWARD + appWidgetId, false)); + ckSkip.setChecked(prefs.getBoolean(PlayerWidget.KEY_WIDGET_SKIP + appWidgetId, false)); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + int color = prefs.getInt(PlayerWidget.KEY_WIDGET_COLOR + appWidgetId, PlayerWidget.DEFAULT_COLOR); + int opacity = Color.alpha(color) * 100 / 0xFF; + + opacitySeekBar.setProgress(opacity, false); + } + displayPreviewPanel(); + } + + private void displayPreviewPanel() { + boolean showExtendedPreview = + ckPlaybackSpeed.isChecked() || ckRewind.isChecked() || ckFastForward.isChecked() || ckSkip.isChecked(); + widgetPreview.findViewById(R.id.extendedButtonsContainer) + .setVisibility(showExtendedPreview ? View.VISIBLE : View.GONE); + widgetPreview.findViewById(R.id.butPlay).setVisibility(showExtendedPreview ? View.GONE : View.VISIBLE); + widgetPreview.findViewById(R.id.butPlaybackSpeed) + .setVisibility(ckPlaybackSpeed.isChecked() ? View.VISIBLE : View.GONE); + widgetPreview.findViewById(R.id.butFastForward) + .setVisibility(ckFastForward.isChecked() ? View.VISIBLE : View.GONE); + widgetPreview.findViewById(R.id.butSkip).setVisibility(ckSkip.isChecked() ? View.VISIBLE : View.GONE); + widgetPreview.findViewById(R.id.butRew).setVisibility(ckRewind.isChecked() ? View.VISIBLE : View.GONE); + } + + private void confirmCreateWidget() { + int backgroundColor = getColorWithAlpha(PlayerWidget.DEFAULT_COLOR, opacitySeekBar.getProgress()); + + SharedPreferences prefs = getSharedPreferences(PlayerWidget.PREFS_NAME, Context.MODE_PRIVATE); + SharedPreferences.Editor editor = prefs.edit(); + editor.putInt(PlayerWidget.KEY_WIDGET_COLOR + appWidgetId, backgroundColor); + editor.putBoolean(PlayerWidget.KEY_WIDGET_PLAYBACK_SPEED + appWidgetId, ckPlaybackSpeed.isChecked()); + editor.putBoolean(PlayerWidget.KEY_WIDGET_SKIP + appWidgetId, ckSkip.isChecked()); + editor.putBoolean(PlayerWidget.KEY_WIDGET_REWIND + appWidgetId, ckRewind.isChecked()); + editor.putBoolean(PlayerWidget.KEY_WIDGET_FAST_FORWARD + appWidgetId, ckFastForward.isChecked()); + editor.apply(); + + Intent resultValue = new Intent(); + resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); + setResult(Activity.RESULT_OK, resultValue); + finish(); + WidgetUpdaterWorker.enqueueWork(this); + } + + private int getColorWithAlpha(int color, int opacity) { + return (int) Math.round(0xFF * (0.01 * opacity)) * 0x1000000 + (color & 0xffffff); + } +} diff --git a/ui/widget/src/main/java/de/danoeh/antennapod/ui/widget/WidgetUpdater.java b/ui/widget/src/main/java/de/danoeh/antennapod/ui/widget/WidgetUpdater.java new file mode 100644 index 000000000..bb62d4a7b --- /dev/null +++ b/ui/widget/src/main/java/de/danoeh/antennapod/ui/widget/WidgetUpdater.java @@ -0,0 +1,226 @@ +package de.danoeh.antennapod.ui.widget; + +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Bitmap; +import android.os.Bundle; +import android.util.Log; +import android.view.KeyEvent; +import android.view.View; +import android.widget.RemoteViews; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.resource.bitmap.FitCenter; +import com.bumptech.glide.load.resource.bitmap.RoundedCorners; +import com.bumptech.glide.request.RequestOptions; + +import de.danoeh.antennapod.ui.appstartintent.MediaButtonStarter; +import de.danoeh.antennapod.ui.common.Converter; +import de.danoeh.antennapod.storage.preferences.UserPreferences; +import java.util.concurrent.TimeUnit; + +import de.danoeh.antennapod.model.playback.MediaType; +import de.danoeh.antennapod.model.playback.Playable; +import de.danoeh.antennapod.playback.base.PlayerStatus; +import de.danoeh.antennapod.ui.appstartintent.MainActivityStarter; +import de.danoeh.antennapod.ui.appstartintent.PlaybackSpeedActivityStarter; +import de.danoeh.antennapod.ui.appstartintent.VideoPlayerActivityStarter; +import de.danoeh.antennapod.ui.episodes.ImageResourceUtils; +import de.danoeh.antennapod.ui.episodes.TimeSpeedConverter; + +/** + * Updates the state of the player widget. + */ +public abstract class WidgetUpdater { + private static final String TAG = "WidgetUpdater"; + + public static class WidgetState { + final Playable media; + final PlayerStatus status; + final int position; + final int duration; + final float playbackSpeed; + + public WidgetState(Playable media, PlayerStatus status, int position, int duration, float playbackSpeed) { + this.media = media; + this.status = status; + this.position = position; + this.duration = duration; + this.playbackSpeed = playbackSpeed; + } + + public WidgetState(PlayerStatus status) { + this(null, status, Playable.INVALID_TIME, Playable.INVALID_TIME, 1.0f); + } + } + + /** + * Update the widgets with the given parameters. Must be called in a background thread. + */ + public static void updateWidget(Context context, WidgetState widgetState) { + if (!PlayerWidget.isEnabled(context) || widgetState == null) { + return; + } + + PendingIntent startMediaPlayer; + if (widgetState.media != null && widgetState.media.getMediaType() == MediaType.VIDEO) { + startMediaPlayer = new VideoPlayerActivityStarter(context).getPendingIntent(); + } else { + startMediaPlayer = new MainActivityStarter(context).withOpenPlayer().getPendingIntent(); + } + + PendingIntent startPlaybackSpeedDialog = new PlaybackSpeedActivityStarter(context).getPendingIntent(); + + RemoteViews views; + views = new RemoteViews(context.getPackageName(), R.layout.player_widget); + + if (widgetState.media != null) { + Bitmap icon; + int iconSize = context.getResources().getDimensionPixelSize(android.R.dimen.app_icon_size); + views.setOnClickPendingIntent(R.id.layout_left, startMediaPlayer); + views.setOnClickPendingIntent(R.id.imgvCover, startMediaPlayer); + views.setOnClickPendingIntent(R.id.butPlaybackSpeed, startPlaybackSpeedDialog); + + int radius = context.getResources().getDimensionPixelSize(R.dimen.widget_inner_radius); + RequestOptions options = new RequestOptions() + .dontAnimate() + .transform(new FitCenter(), new RoundedCorners(radius)); + + try { + icon = Glide.with(context) + .asBitmap() + .load(widgetState.media.getImageLocation()) + .apply(options) + .submit(iconSize, iconSize) + .get(500, TimeUnit.MILLISECONDS); + views.setImageViewBitmap(R.id.imgvCover, icon); + } catch (Throwable tr1) { + try { + icon = Glide.with(context) + .asBitmap() + .load(ImageResourceUtils.getFallbackImageLocation(widgetState.media)) + .apply(options) + .submit(iconSize, iconSize) + .get(500, TimeUnit.MILLISECONDS); + views.setImageViewBitmap(R.id.imgvCover, icon); + } catch (Throwable tr2) { + Log.e(TAG, "Error loading the media icon for the widget", tr2); + views.setImageViewResource(R.id.imgvCover, R.mipmap.ic_launcher); + } + } + + views.setTextViewText(R.id.txtvTitle, widgetState.media.getEpisodeTitle()); + views.setViewVisibility(R.id.txtvTitle, View.VISIBLE); + views.setViewVisibility(R.id.txtNoPlaying, View.GONE); + + String progressString = getProgressString(widgetState.position, + widgetState.duration, widgetState.playbackSpeed); + if (progressString != null) { + views.setViewVisibility(R.id.txtvProgress, View.VISIBLE); + views.setTextViewText(R.id.txtvProgress, progressString); + } + + if (widgetState.status == PlayerStatus.PLAYING) { + views.setImageViewResource(R.id.butPlay, R.drawable.ic_widget_pause); + views.setContentDescription(R.id.butPlay, context.getString(R.string.pause_label)); + views.setImageViewResource(R.id.butPlayExtended, R.drawable.ic_widget_pause); + views.setContentDescription(R.id.butPlayExtended, context.getString(R.string.pause_label)); + } else { + views.setImageViewResource(R.id.butPlay, R.drawable.ic_widget_play); + views.setContentDescription(R.id.butPlay, context.getString(R.string.play_label)); + views.setImageViewResource(R.id.butPlayExtended, R.drawable.ic_widget_play); + views.setContentDescription(R.id.butPlayExtended, context.getString(R.string.play_label)); + } + views.setOnClickPendingIntent(R.id.butPlay, + MediaButtonStarter.createPendingIntent(context, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)); + views.setOnClickPendingIntent(R.id.butPlayExtended, + MediaButtonStarter.createPendingIntent(context, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)); + views.setOnClickPendingIntent(R.id.butRew, + MediaButtonStarter.createPendingIntent(context, KeyEvent.KEYCODE_MEDIA_REWIND)); + views.setOnClickPendingIntent(R.id.butFastForward, + MediaButtonStarter.createPendingIntent(context, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD)); + views.setOnClickPendingIntent(R.id.butSkip, + MediaButtonStarter.createPendingIntent(context, KeyEvent.KEYCODE_MEDIA_NEXT)); + } else { + // start the app if they click anything + views.setOnClickPendingIntent(R.id.layout_left, startMediaPlayer); + views.setOnClickPendingIntent(R.id.butPlay, startMediaPlayer); + views.setOnClickPendingIntent(R.id.butPlayExtended, + MediaButtonStarter.createPendingIntent(context, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)); + views.setViewVisibility(R.id.txtvProgress, View.GONE); + views.setViewVisibility(R.id.txtvTitle, View.GONE); + views.setViewVisibility(R.id.txtNoPlaying, View.VISIBLE); + views.setImageViewResource(R.id.imgvCover, R.mipmap.ic_launcher); + views.setImageViewResource(R.id.butPlay, R.drawable.ic_widget_play); + views.setImageViewResource(R.id.butPlayExtended, R.drawable.ic_widget_play); + } + + ComponentName playerWidget = new ComponentName(context, PlayerWidget.class); + AppWidgetManager manager = AppWidgetManager.getInstance(context); + int[] widgetIds = manager.getAppWidgetIds(playerWidget); + + for (int id : widgetIds) { + Bundle options = manager.getAppWidgetOptions(id); + SharedPreferences prefs = context.getSharedPreferences(PlayerWidget.PREFS_NAME, Context.MODE_PRIVATE); + int minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH); + int columns = getCellsForSize(minWidth); + if (columns < 3) { + views.setViewVisibility(R.id.layout_center, View.INVISIBLE); + } else { + views.setViewVisibility(R.id.layout_center, View.VISIBLE); + } + boolean showPlaybackSpeed = prefs.getBoolean(PlayerWidget.KEY_WIDGET_PLAYBACK_SPEED + id, false); + boolean showRewind = prefs.getBoolean(PlayerWidget.KEY_WIDGET_REWIND + id, false); + boolean showFastForward = prefs.getBoolean(PlayerWidget.KEY_WIDGET_FAST_FORWARD + id, false); + boolean showSkip = prefs.getBoolean(PlayerWidget.KEY_WIDGET_SKIP + id, false); + + if (showPlaybackSpeed || showRewind || showSkip || showFastForward) { + views.setInt(R.id.extendedButtonsContainer, "setVisibility", View.VISIBLE); + views.setInt(R.id.butPlay, "setVisibility", View.GONE); + views.setInt(R.id.butPlaybackSpeed, "setVisibility", showPlaybackSpeed ? View.VISIBLE : View.GONE); + views.setInt(R.id.butRew, "setVisibility", showRewind ? View.VISIBLE : View.GONE); + views.setInt(R.id.butFastForward, "setVisibility", showFastForward ? View.VISIBLE : View.GONE); + views.setInt(R.id.butSkip, "setVisibility", showSkip ? View.VISIBLE : View.GONE); + } else { + views.setInt(R.id.extendedButtonsContainer, "setVisibility", View.GONE); + views.setInt(R.id.butPlay, "setVisibility", View.VISIBLE); + } + + int backgroundColor = prefs.getInt(PlayerWidget.KEY_WIDGET_COLOR + id, PlayerWidget.DEFAULT_COLOR); + views.setInt(R.id.widgetLayout, "setBackgroundColor", backgroundColor); + + manager.updateAppWidget(id, views); + } + } + + /** + * Returns number of cells needed for given size of the widget. + * + * @param size Widget size in dp. + * @return Size in number of cells. + */ + private static int getCellsForSize(int size) { + int n = 2; + while (70 * n - 30 < size) { + ++n; + } + return n - 1; + } + + private static String getProgressString(int position, int duration, float speed) { + if (position < 0 || duration <= 0) { + return null; + } + TimeSpeedConverter converter = new TimeSpeedConverter(speed); + if (UserPreferences.shouldShowRemainingTime()) { + return Converter.getDurationStringLong(converter.convert(position)) + " / -" + + Converter.getDurationStringLong(converter.convert(Math.max(0, duration - position))); + } else { + return Converter.getDurationStringLong(converter.convert(position)) + " / " + + Converter.getDurationStringLong(converter.convert(duration)); + } + } +} diff --git a/ui/widget/src/main/java/de/danoeh/antennapod/ui/widget/WidgetUpdaterWorker.java b/ui/widget/src/main/java/de/danoeh/antennapod/ui/widget/WidgetUpdaterWorker.java new file mode 100644 index 000000000..97b0790ac --- /dev/null +++ b/ui/widget/src/main/java/de/danoeh/antennapod/ui/widget/WidgetUpdaterWorker.java @@ -0,0 +1,58 @@ +package de.danoeh.antennapod.ui.widget; + +import android.content.Context; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.work.ExistingWorkPolicy; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkManager; +import androidx.work.Worker; +import androidx.work.WorkerParameters; +import de.danoeh.antennapod.storage.database.DBReader; +import de.danoeh.antennapod.storage.preferences.PlaybackPreferences; +import de.danoeh.antennapod.model.playback.Playable; +import de.danoeh.antennapod.playback.base.PlayerStatus; +import de.danoeh.antennapod.ui.episodes.PlaybackSpeedUtils; + +public class WidgetUpdaterWorker extends Worker { + + private static final String TAG = "WidgetUpdaterWorker"; + + public WidgetUpdaterWorker(@NonNull final Context context, + @NonNull final WorkerParameters workerParams) { + super(context, workerParams); + } + + public static void enqueueWork(final Context context) { + final OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(WidgetUpdaterWorker.class).build(); + WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, workRequest); + } + + @NonNull + @Override + public Result doWork() { + try { + updateWidget(); + } catch (final Exception e) { + Log.d(TAG, "Failed to update AntennaPod widget: ", e); + return Result.failure(); + } + return Result.success(); + } + + /** + * Loads the current media from the database and updates the widget in a background job. + */ + private void updateWidget() { + final Playable media = DBReader.getFeedMedia(PlaybackPreferences.getCurrentlyPlayingFeedMediaId()); + if (media != null) { + WidgetUpdater.updateWidget(getApplicationContext(), + new WidgetUpdater.WidgetState(media, PlayerStatus.STOPPED, + media.getPosition(), media.getDuration(), + PlaybackSpeedUtils.getCurrentPlaybackSpeed(media))); + } else { + WidgetUpdater.updateWidget(getApplicationContext(), + new WidgetUpdater.WidgetState(PlayerStatus.STOPPED)); + } + } +} diff --git a/ui/widget/src/main/res/drawable-hdpi/ic_widget_preview.png b/ui/widget/src/main/res/drawable-hdpi/ic_widget_preview.png Binary files differnew file mode 100644 index 000000000..3c1e08a31 --- /dev/null +++ b/ui/widget/src/main/res/drawable-hdpi/ic_widget_preview.png diff --git a/ui/widget/src/main/res/layout/activity_widget_config.xml b/ui/widget/src/main/res/layout/activity_widget_config.xml new file mode 100644 index 000000000..04be2dfa2 --- /dev/null +++ b/ui/widget/src/main/res/layout/activity_widget_config.xml @@ -0,0 +1,124 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical" + tools:context="de.danoeh.antennapod.ui.widget.WidgetConfigActivity"> + + <FrameLayout + android:layout_width="match_parent" + android:layout_height="200dp" + android:layout_gravity="center"> + + <ImageView + android:id="@+id/widget_config_background" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:scaleType="centerCrop" + app:srcCompat="@drawable/teaser" /> + + <include + android:id="@+id/widget_config_preview" + android:layout_width="match_parent" + android:layout_height="96dp" + android:layout_gravity="center" + android:layout_margin="16dp" + layout="@layout/player_widget" /> + + </FrameLayout> + + <androidx.core.widget.NestedScrollView + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical" + android:padding="16dp"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center_vertical" + android:orientation="horizontal"> + + <TextView + android:id="@+id/textView" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/widget_opacity" + android:textSize="16sp" + android:textColor="?android:attr/textColorPrimary" /> + + <TextView + android:id="@+id/widget_opacity_textView" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:gravity="end" + android:text="100%" + android:textSize="16sp" + android:textColor="?android:attr/textColorSecondary" /> + + </LinearLayout> + + <SeekBar + android:id="@+id/widget_opacity_seekBar" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="16dp" + android:layout_marginBottom="16dp" + android:max="100" + android:progress="100" /> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> + + <CheckBox + android:id="@+id/ckPlaybackSpeed" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="@string/playback_speed" /> + + <CheckBox + android:id="@+id/ckRewind" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="@string/rewind_label" /> + + <CheckBox + android:id="@+id/ckFastForward" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="@string/fast_forward_label" /> + + <CheckBox + android:id="@+id/ckSkip" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="@string/skip_episode_label" /> + + </LinearLayout> + + <Button + android:id="@+id/butConfirm" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:text="@string/widget_create_button" /> + + </LinearLayout> + + </androidx.core.widget.NestedScrollView> + +</LinearLayout> diff --git a/ui/widget/src/main/res/layout/player_widget.xml b/ui/widget/src/main/res/layout/player_widget.xml new file mode 100644 index 000000000..616e61e91 --- /dev/null +++ b/ui/widget/src/main/res/layout/player_widget.xml @@ -0,0 +1,153 @@ +<?xml version="1.0" encoding="utf-8"?> +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:padding="@dimen/widget_margin"> + + <RelativeLayout + android:id="@+id/widgetLayout" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="#262C31" + tools:ignore="UselessParent"> + + <ImageButton + android:id="@+id/butPlay" + android:layout_width="@android:dimen/app_icon_size" + android:layout_height="match_parent" + android:contentDescription="@string/play_label" + android:layout_alignParentEnd="true" + android:layout_margin="12dp" + android:background="?android:attr/selectableItemBackground" + android:scaleType="fitCenter" + android:padding="8dp" + android:src="@drawable/ic_widget_play" /> + + <LinearLayout + android:id="@+id/layout_left" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_alignParentStart="true" + android:layout_toStartOf="@id/butPlay" + android:background="@android:color/transparent" + android:gravity="fill_horizontal" + android:orientation="horizontal"> + + <ImageView + android:id="@+id/imgvCover" + android:layout_width="@android:dimen/app_icon_size" + android:layout_height="match_parent" + android:src="@mipmap/ic_launcher" + android:importantForAccessibility="no" + android:layout_margin="12dp" /> + + <LinearLayout + android:id="@+id/layout_center" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:gravity="center_vertical" + android:orientation="vertical"> + + <TextView + android:id="@+id/txtNoPlaying" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:maxLines="3" + android:text="@string/no_media_playing_label" + android:textColor="@color/white" + android:textSize="16sp" + android:textStyle="bold" /> + + <TextView + android:id="@+id/txtvTitle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:maxLines="1" + android:ellipsize="end" + android:textColor="@color/white" + android:textSize="16sp" + android:textStyle="bold" + android:visibility="gone" /> + + <TextView + android:id="@+id/txtvProgress" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="4dp" + android:textColor="@color/white" + android:textSize="14sp" + android:visibility="gone" /> + + <LinearLayout + android:id="@+id/extendedButtonsContainer" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:visibility="gone"> + + <ImageButton + android:id="@+id/butPlaybackSpeed" + android:layout_width="36dp" + android:layout_height="36dp" + android:layout_weight="1" + android:background="?android:attr/selectableItemBackground" + android:contentDescription="@string/playback_speed" + android:layout_marginEnd="2dp" + android:scaleType="centerInside" + android:src="@drawable/ic_widget_playback_speed" /> + + <ImageButton + android:id="@+id/butRew" + android:layout_width="36dp" + android:layout_height="36dp" + android:layout_weight="1" + android:background="?android:attr/selectableItemBackground" + android:contentDescription="@string/rewind_label" + android:layout_marginEnd="2dp" + android:scaleType="centerInside" + android:src="@drawable/ic_widget_fast_rewind" /> + + <ImageButton + android:id="@+id/butPlayExtended" + android:layout_width="36dp" + android:layout_height="36dp" + android:layout_weight="1" + android:background="?android:attr/selectableItemBackground" + android:contentDescription="@string/play_label" + android:layout_marginEnd="2dp" + android:scaleType="centerInside" + android:src="@drawable/ic_widget_play" /> + + <ImageButton + android:id="@+id/butFastForward" + android:layout_width="36dp" + android:layout_height="36dp" + android:layout_weight="1" + android:background="?android:attr/selectableItemBackground" + android:contentDescription="@string/fast_forward_label" + android:layout_marginEnd="2dp" + android:scaleType="centerInside" + android:src="@drawable/ic_widget_fast_forward" /> + + <ImageButton + android:id="@+id/butSkip" + android:layout_width="36dp" + android:layout_height="36dp" + android:layout_weight="1" + android:background="?android:attr/selectableItemBackground" + android:contentDescription="@string/skip_episode_label" + android:layout_marginEnd="2dp" + android:scaleType="centerInside" + android:src="@drawable/ic_widget_skip" /> + + </LinearLayout> + + </LinearLayout> + + </LinearLayout> + + </RelativeLayout> + +</FrameLayout> diff --git a/ui/widget/src/main/res/values/dimens.xml b/ui/widget/src/main/res/values/dimens.xml new file mode 100644 index 000000000..d0c504ac5 --- /dev/null +++ b/ui/widget/src/main/res/values/dimens.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <dimen name="widget_margin">0dp</dimen> + <dimen name="widget_inner_radius">4dp</dimen> +</resources> diff --git a/ui/widget/src/main/res/xml/player_widget_info.xml b/ui/widget/src/main/res/xml/player_widget_info.xml new file mode 100644 index 000000000..0bbec5dda --- /dev/null +++ b/ui/widget/src/main/res/xml/player_widget_info.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> +<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" + android:resizeMode="horizontal" + android:initialLayout="@layout/player_widget" + android:updatePeriodMillis="86400000" + android:previewImage="@drawable/ic_widget_preview" + android:minHeight="40dp" + android:minWidth="250dp" + android:minResizeWidth="40dp" + android:widgetFeatures="reconfigurable" + android:configure="de.danoeh.antennapod.ui.widget.WidgetConfigActivity"> +</appwidget-provider> |