package de.danoeh.antennapod.ui.screen.playback.audio; import android.content.Intent; import android.os.Bundle; import android.util.Log; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.ImageButton; import android.widget.ProgressBar; import android.widget.SeekBar; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.cardview.widget.CardView; import androidx.fragment.app.Fragment; import androidx.interpolator.view.animation.FastOutSlowInInterpolator; import androidx.viewpager2.adapter.FragmentStateAdapter; import androidx.viewpager2.widget.ViewPager2; import com.google.android.material.appbar.MaterialToolbar; import com.google.android.material.bottomsheet.BottomSheetBehavior; import com.google.android.material.elevation.SurfaceColors; import de.danoeh.antennapod.playback.service.PlaybackController; import de.danoeh.antennapod.ui.appstartintent.MediaButtonStarter; import de.danoeh.antennapod.ui.chapters.ChapterUtils; import de.danoeh.antennapod.ui.episodes.PlaybackSpeedUtils; import de.danoeh.antennapod.ui.episodes.TimeSpeedConverter; import de.danoeh.antennapod.ui.screen.playback.MediaPlayerErrorDialog; import de.danoeh.antennapod.ui.screen.playback.PlayButton; import de.danoeh.antennapod.ui.screen.playback.SleepTimerDialog; import de.danoeh.antennapod.ui.screen.playback.VariableSpeedDialog; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import java.text.DecimalFormat; import java.text.NumberFormat; import java.util.List; import de.danoeh.antennapod.R; import de.danoeh.antennapod.activity.MainActivity; import de.danoeh.antennapod.ui.common.Converter; import de.danoeh.antennapod.ui.screen.feed.preferences.SkipPreferenceDialog; import de.danoeh.antennapod.event.FavoritesEvent; import de.danoeh.antennapod.event.PlayerErrorEvent; import de.danoeh.antennapod.event.UnreadItemsUpdateEvent; import de.danoeh.antennapod.event.playback.BufferUpdateEvent; import de.danoeh.antennapod.event.playback.PlaybackPositionEvent; import de.danoeh.antennapod.event.playback.PlaybackServiceEvent; import de.danoeh.antennapod.event.playback.SleepTimerUpdatedEvent; import de.danoeh.antennapod.event.playback.SpeedChangedEvent; import de.danoeh.antennapod.ui.episodeslist.FeedItemMenuHandler; import de.danoeh.antennapod.model.feed.Chapter; import de.danoeh.antennapod.model.feed.FeedItem; import de.danoeh.antennapod.model.feed.FeedMedia; import de.danoeh.antennapod.model.playback.Playable; import de.danoeh.antennapod.playback.cast.CastEnabledActivity; import de.danoeh.antennapod.storage.preferences.UserPreferences; import de.danoeh.antennapod.ui.common.PlaybackSpeedIndicatorView; import io.reactivex.Maybe; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; /** * Shows the audio player. */ public class AudioPlayerFragment extends Fragment implements ChapterSeekBar.OnSeekBarChangeListener, MaterialToolbar.OnMenuItemClickListener { public static final String TAG = "AudioPlayerFragment"; public static final int POS_COVER = 0; public static final int POS_DESCRIPTION = 1; private static final int NUM_CONTENT_FRAGMENTS = 2; PlaybackSpeedIndicatorView butPlaybackSpeed; TextView txtvPlaybackSpeed; private ViewPager2 pager; private TextView txtvPosition; private TextView txtvLength; private ChapterSeekBar sbPosition; private ImageButton butRev; private TextView txtvRev; private PlayButton butPlay; private ImageButton butFF; private TextView txtvFF; private ImageButton butSkip; private MaterialToolbar toolbar; private ProgressBar progressIndicator; private CardView cardViewSeek; private TextView txtvSeek; private PlaybackController controller; private Disposable disposable; private boolean showTimeLeft; private boolean seekedToChapterStart = false; private int currentChapterIndex = -1; private int duration; @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { super.onCreateView(inflater, container, savedInstanceState); View root = inflater.inflate(R.layout.audioplayer_fragment, container, false); root.setOnTouchListener((v, event) -> true); // Avoid clicks going through player to fragments below toolbar = root.findViewById(R.id.toolbar); toolbar.setTitle(""); toolbar.setNavigationOnClickListener(v -> ((MainActivity) getActivity()).getBottomSheet().setState(BottomSheetBehavior.STATE_COLLAPSED)); toolbar.setOnMenuItemClickListener(this); ExternalPlayerFragment externalPlayerFragment = new ExternalPlayerFragment(); getChildFragmentManager().beginTransaction() .replace(R.id.playerFragment, externalPlayerFragment, ExternalPlayerFragment.TAG) .commit(); root.findViewById(R.id.playerFragment).setBackgroundColor( SurfaceColors.getColorForElevation(getContext(), 8 * getResources().getDisplayMetrics().density)); butPlaybackSpeed = root.findViewById(R.id.butPlaybackSpeed); txtvPlaybackSpeed = root.findViewById(R.id.txtvPlaybackSpeed); sbPosition = root.findViewById(R.id.sbPosition); txtvPosition = root.findViewById(R.id.txtvPosition); txtvLength = root.findViewById(R.id.txtvLength); butRev = root.findViewById(R.id.butRev); txtvRev = root.findViewById(R.id.txtvRev); butPlay = root.findViewById(R.id.butPlay); butFF = root.findViewById(R.id.butFF); txtvFF = root.findViewById(R.id.txtvFF); butSkip = root.findViewById(R.id.butSkip); progressIndicator = root.findViewById(R.id.progLoading); cardViewSeek = root.findViewById(R.id.cardViewSeek); txtvSeek = root.findViewById(R.id.txtvSeek); setupLengthTextView(); setupControlButtons(); butPlaybackSpeed.setOnClickListener(v -> new VariableSpeedDialog().show(getChildFragmentManager(), null)); sbPosition.setOnSeekBarChangeListener(this); pager = root.findViewById(R.id.pager); pager.setAdapter(new AudioPlayerPagerAdapter(this)); // Required for getChildAt(int) in ViewPagerBottomSheetBehavior to return the correct page pager.setOffscreenPageLimit((int) NUM_CONTENT_FRAGMENTS); pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { @Override public void onPageSelected(int position) { pager.post(() -> { if (getActivity() != null) { // By the time this is posted, the activity might be closed again. ((MainActivity) getActivity()).getBottomSheet().updateScrollingChild(); } }); } }); return root; } private void setChapterDividers(Playable media) { if (media == null) { return; } float[] dividerPos = null; if (media.getChapters() != null && !media.getChapters().isEmpty()) { List chapters = media.getChapters(); dividerPos = new float[chapters.size()]; for (int i = 0; i < chapters.size(); i++) { dividerPos[i] = chapters.get(i).getStart() / (float) duration; } } sbPosition.setDividerPos(dividerPos); } private void setupControlButtons() { butRev.setOnClickListener(v -> { if (controller != null) { int curr = controller.getPosition(); controller.seekTo(curr - UserPreferences.getRewindSecs() * 1000); } }); butRev.setOnLongClickListener(v -> { SkipPreferenceDialog.showSkipPreference(getContext(), SkipPreferenceDialog.SkipDirection.SKIP_REWIND, txtvRev); return true; }); butPlay.setOnClickListener(v -> { if (controller != null) { controller.init(); controller.playPause(); } }); butFF.setOnClickListener(v -> { if (controller != null) { int curr = controller.getPosition(); controller.seekTo(curr + UserPreferences.getFastForwardSecs() * 1000); } }); butFF.setOnLongClickListener(v -> { SkipPreferenceDialog.showSkipPreference(getContext(), SkipPreferenceDialog.SkipDirection.SKIP_FORWARD, txtvFF); return false; }); butSkip.setOnClickListener(v -> getActivity().sendBroadcast( MediaButtonStarter.createIntent(getContext(), KeyEvent.KEYCODE_MEDIA_NEXT))); } @Subscribe(threadMode = ThreadMode.MAIN) public void onUnreadItemsUpdate(UnreadItemsUpdateEvent event) { if (controller == null) { return; } updatePosition(new PlaybackPositionEvent(controller.getPosition(), controller.getDuration())); } @Subscribe(threadMode = ThreadMode.MAIN) public void onPlaybackServiceChanged(PlaybackServiceEvent event) { if (event.action == PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN) { ((MainActivity) getActivity()).getBottomSheet().setState(BottomSheetBehavior.STATE_COLLAPSED); } } private void setupLengthTextView() { showTimeLeft = UserPreferences.shouldShowRemainingTime(); txtvLength.setOnClickListener(v -> { if (controller == null) { return; } showTimeLeft = !showTimeLeft; UserPreferences.setShowRemainTimeSetting(showTimeLeft); updatePosition(new PlaybackPositionEvent(controller.getPosition(), controller.getDuration())); }); } @Subscribe(threadMode = ThreadMode.MAIN) public void updatePlaybackSpeedButton(SpeedChangedEvent event) { String speedStr = new DecimalFormat("0.00").format(event.getNewSpeed()); txtvPlaybackSpeed.setText(speedStr); butPlaybackSpeed.setSpeed(event.getNewSpeed()); } private void loadMediaInfo(boolean includingChapters) { if (disposable != null) { disposable.dispose(); } disposable = Maybe.create(emitter -> { Playable media = controller.getMedia(); if (media != null) { if (includingChapters) { ChapterUtils.loadChapters(media, getContext(), false); } emitter.onSuccess(media); } else { emitter.onComplete(); } }) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(media -> { updateUi(media); if (media.getChapters() == null && !includingChapters) { loadMediaInfo(true); } }, error -> Log.e(TAG, Log.getStackTraceString(error)), () -> updateUi(null)); } private PlaybackController newPlaybackController() { return new PlaybackController(getActivity()) { @Override protected void updatePlayButtonShowsPlay(boolean showPlay) { butPlay.setIsShowPlay(showPlay); } @Override public void loadMediaInfo() { AudioPlayerFragment.this.loadMediaInfo(false); } @Override public void onPlaybackEnd() { ((MainActivity) getActivity()).getBottomSheet().setState(BottomSheetBehavior.STATE_COLLAPSED); } }; } private void updateUi(Playable media) { if (controller == null || media == null) { return; } duration = controller.getDuration(); updatePosition(new PlaybackPositionEvent(media.getPosition(), media.getDuration())); updatePlaybackSpeedButton(new SpeedChangedEvent(PlaybackSpeedUtils.getCurrentPlaybackSpeed(media))); setChapterDividers(media); setupOptionsMenu(media); } @Subscribe(threadMode = ThreadMode.MAIN) @SuppressWarnings("unused") public void sleepTimerUpdate(SleepTimerUpdatedEvent event) { if (event.isCancelled() || event.wasJustEnabled()) { AudioPlayerFragment.this.loadMediaInfo(false); } } @Override public void onStart() { super.onStart(); controller = newPlaybackController(); controller.init(); loadMediaInfo(false); EventBus.getDefault().register(this); txtvRev.setText(NumberFormat.getInstance().format(UserPreferences.getRewindSecs())); txtvFF.setText(NumberFormat.getInstance().format(UserPreferences.getFastForwardSecs())); } @Override public void onStop() { super.onStop(); controller.release(); controller = null; progressIndicator.setVisibility(View.GONE); // Controller released; we will not receive buffering updates EventBus.getDefault().unregister(this); if (disposable != null) { disposable.dispose(); } } @Subscribe(threadMode = ThreadMode.MAIN) @SuppressWarnings("unused") public void bufferUpdate(BufferUpdateEvent event) { if (event.hasStarted()) { progressIndicator.setVisibility(View.VISIBLE); } else if (event.hasEnded()) { progressIndicator.setVisibility(View.GONE); } else if (controller != null && controller.isStreaming()) { sbPosition.setSecondaryProgress((int) (event.getProgress() * sbPosition.getMax())); } else { sbPosition.setSecondaryProgress(0); } } @Subscribe(threadMode = ThreadMode.MAIN) public void updatePosition(PlaybackPositionEvent event) { if (controller == null || txtvPosition == null || txtvLength == null || sbPosition == null) { return; } TimeSpeedConverter converter = new TimeSpeedConverter(controller.getCurrentPlaybackSpeedMultiplier()); int currentPosition = converter.convert(event.getPosition()); int duration = converter.convert(event.getDuration()); int remainingTime = converter.convert(Math.max(event.getDuration() - event.getPosition(), 0)); currentChapterIndex = Chapter.getAfterPosition(controller.getMedia().getChapters(), currentPosition); Log.d(TAG, "currentPosition " + Converter.getDurationStringLong(currentPosition)); if (currentPosition == Playable.INVALID_TIME || duration == Playable.INVALID_TIME) { Log.w(TAG, "Could not react to position observer update because of invalid time"); return; } txtvPosition.setText(Converter.getDurationStringLong(currentPosition)); txtvPosition.setContentDescription(getString(R.string.position, Converter.getDurationStringLocalized(getContext(), currentPosition))); showTimeLeft = UserPreferences.shouldShowRemainingTime(); if (showTimeLeft) { txtvLength.setContentDescription(getString(R.string.remaining_time, Converter.getDurationStringLocalized(getContext(), remainingTime))); txtvLength.setText(((remainingTime > 0) ? "-" : "") + Converter.getDurationStringLong(remainingTime)); } else { txtvLength.setContentDescription(getString(R.string.chapter_duration, Converter.getDurationStringLocalized(getContext(), duration))); txtvLength.setText(Converter.getDurationStringLong(duration)); } if (!sbPosition.isPressed()) { float progress = ((float) event.getPosition()) / event.getDuration(); sbPosition.setProgress((int) (progress * sbPosition.getMax())); } } @Subscribe(threadMode = ThreadMode.MAIN) public void favoritesChanged(FavoritesEvent event) { AudioPlayerFragment.this.loadMediaInfo(false); } @Subscribe(threadMode = ThreadMode.MAIN) public void mediaPlayerError(PlayerErrorEvent event) { MediaPlayerErrorDialog.show(getActivity(), event); } @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { if (controller == null || txtvLength == null) { return; } if (fromUser) { float prog = progress / ((float) seekBar.getMax()); TimeSpeedConverter converter = new TimeSpeedConverter(controller.getCurrentPlaybackSpeedMultiplier()); int position = converter.convert((int) (prog * controller.getDuration())); int newChapterIndex = Chapter.getAfterPosition(controller.getMedia().getChapters(), position); if (newChapterIndex > -1) { if (!sbPosition.isPressed() && currentChapterIndex != newChapterIndex) { currentChapterIndex = newChapterIndex; position = (int) controller.getMedia().getChapters().get(currentChapterIndex).getStart(); seekedToChapterStart = true; controller.seekTo(position); updateUi(controller.getMedia()); sbPosition.highlightCurrentChapter(); } txtvSeek.setText(controller.getMedia().getChapters().get(newChapterIndex).getTitle() + "\n" + Converter.getDurationStringLong(position)); } else { txtvSeek.setText(Converter.getDurationStringLong(position)); } } else if (duration != controller.getDuration()) { updateUi(controller.getMedia()); } } @Override public void onStartTrackingTouch(SeekBar seekBar) { // interrupt position Observer, restart later cardViewSeek.setScaleX(.8f); cardViewSeek.setScaleY(.8f); cardViewSeek.animate() .setInterpolator(new FastOutSlowInInterpolator()) .alpha(1f).scaleX(1f).scaleY(1f) .setDuration(200) .start(); } @Override public void onStopTrackingTouch(SeekBar seekBar) { if (controller != null) { if (seekedToChapterStart) { seekedToChapterStart = false; } else { float prog = seekBar.getProgress() / ((float) seekBar.getMax()); controller.seekTo((int) (prog * controller.getDuration())); } } cardViewSeek.setScaleX(1f); cardViewSeek.setScaleY(1f); cardViewSeek.animate() .setInterpolator(new FastOutSlowInInterpolator()) .alpha(0f).scaleX(.8f).scaleY(.8f) .setDuration(200) .start(); } public void setupOptionsMenu(Playable media) { if (toolbar.getMenu().size() == 0) { toolbar.inflateMenu(R.menu.mediaplayer); } if (controller == null) { return; } boolean isFeedMedia = media instanceof FeedMedia; toolbar.getMenu().findItem(R.id.open_feed_item).setVisible(isFeedMedia); if (isFeedMedia) { FeedItemMenuHandler.onPrepareMenu(toolbar.getMenu(), ((FeedMedia) media).getItem()); } toolbar.getMenu().findItem(R.id.set_sleeptimer_item).setVisible(!controller.sleepTimerActive()); toolbar.getMenu().findItem(R.id.disable_sleeptimer_item).setVisible(controller.sleepTimerActive()); ((CastEnabledActivity) getActivity()).requestCastButton(toolbar.getMenu()); } @Override public boolean onMenuItemClick(MenuItem item) { if (controller == null) { return false; } Playable media = controller.getMedia(); if (media == null) { return false; } final @Nullable FeedItem feedItem = (media instanceof FeedMedia) ? ((FeedMedia) media).getItem() : null; if (feedItem != null && FeedItemMenuHandler.onMenuItemClicked(this, item.getItemId(), feedItem)) { return true; } final int itemId = item.getItemId(); if (itemId == R.id.disable_sleeptimer_item || itemId == R.id.set_sleeptimer_item) { new SleepTimerDialog().show(getChildFragmentManager(), "SleepTimerDialog"); return true; } else if (itemId == R.id.open_feed_item) { if (feedItem != null) { Intent intent = MainActivity.getIntentToOpenFeed(getContext(), feedItem.getFeedId()); startActivity(intent); } return true; } return false; } public void fadePlayerToToolbar(float slideOffset) { float playerFadeProgress = Math.max(0.0f, Math.min(0.2f, slideOffset - 0.2f)) / 0.2f; View player = getView().findViewById(R.id.playerFragment); player.setAlpha(1 - playerFadeProgress); player.setVisibility(playerFadeProgress > 0.99f ? View.INVISIBLE : View.VISIBLE); float toolbarFadeProgress = Math.max(0.0f, Math.min(0.2f, slideOffset - 0.6f)) / 0.2f; toolbar.setAlpha(toolbarFadeProgress); toolbar.setVisibility(toolbarFadeProgress < 0.01f ? View.INVISIBLE : View.VISIBLE); } private static class AudioPlayerPagerAdapter extends FragmentStateAdapter { private static final String TAG = "AudioPlayerPagerAdapter"; public AudioPlayerPagerAdapter(@NonNull Fragment fragment) { super(fragment); } @NonNull @Override public Fragment createFragment(int position) { Log.d(TAG, "getItem(" + position + ")"); switch (position) { case POS_COVER: return new CoverFragment(); default: case POS_DESCRIPTION: return new ItemDescriptionFragment(); } } @Override public int getItemCount() { return NUM_CONTENT_FRAGMENTS; } } public void scrollToPage(int page, boolean smoothScroll) { if (pager == null) { return; } pager.setCurrentItem(page, smoothScroll); Fragment visibleChild = getChildFragmentManager().findFragmentByTag("f" + POS_DESCRIPTION); if (visibleChild instanceof ItemDescriptionFragment) { ((ItemDescriptionFragment) visibleChild).scrollToTop(); } } public void scrollToPage(int page) { scrollToPage(page, false); } }