From 94c864c8acb6c58130dbccc0ae6d1cbf5f9f9fd9 Mon Sep 17 00:00:00 2001 From: Grishka Date: Wed, 20 Apr 2022 15:23:52 +0300 Subject: [PATCH] Photo viewer & video player UI --- mastodon/src/main/AndroidManifest.xml | 1 + .../fragments/AccountTimelineFragment.java | 2 + .../fragments/BaseStatusListFragment.java | 19 + .../android/ui/photoviewer/PhotoViewer.java | 468 +++++++++++++++++- .../android/ui/photoviewer/ZoomPanView.java | 14 +- .../ic_fluent_arrow_download_24_regular.xml | 3 + .../res/drawable/seekbar_video_player.xml | 28 ++ .../drawable/seekbar_video_player_thumb.xml | 5 + .../src/main/res/layout/photo_viewer_ui.xml | 79 +++ mastodon/src/main/res/values/strings.xml | 7 + 10 files changed, 620 insertions(+), 6 deletions(-) create mode 100644 mastodon/src/main/res/drawable/ic_fluent_arrow_download_24_regular.xml create mode 100644 mastodon/src/main/res/drawable/seekbar_video_player.xml create mode 100644 mastodon/src/main/res/drawable/seekbar_video_player_thumb.xml create mode 100644 mastodon/src/main/res/layout/photo_viewer_ui.xml diff --git a/mastodon/src/main/AndroidManifest.xml b/mastodon/src/main/AndroidManifest.xml index bb9b716d4..8b43ff38f 100644 --- a/mastodon/src/main/AndroidManifest.xml +++ b/mastodon/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java index 751b1a7e9..f8d7c6b8c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java @@ -51,6 +51,8 @@ public class AccountTimelineFragment extends StatusListFragment{ .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(List result){ + if(getActivity()==null) + return; onDataLoaded(result, !result.isEmpty()); } }) diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java index 6fe518a72..94cf6dd78 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java @@ -234,6 +234,11 @@ public abstract class BaseStatusListFragment exten currentPhotoViewer=null; } + @Override + public void onRequestPermissions(String[] permissions){ + requestPermissions(permissions, PhotoViewer.PERMISSION_REQUEST); + } + private ImageStatusDisplayItem.Holder findPhotoViewHolder(int index){ int offset=0; for(StatusDisplayItem item:displayItems){ @@ -562,6 +567,20 @@ public abstract class BaseStatusListFragment exten super.onApplyWindowInsets(insets); } + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults){ + if(requestCode==PhotoViewer.PERMISSION_REQUEST && currentPhotoViewer!=null){ + currentPhotoViewer.onRequestPermissionsResult(permissions, grantResults); + } + } + + @Override + public void onPause(){ + super.onPause(); + if(currentPhotoViewer!=null) + currentPhotoViewer.onPause(); + } + protected class DisplayItemsAdapter extends UsableRecyclerView.Adapter> implements ImageLoaderRecyclerAdapter{ public DisplayItemsAdapter(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java index ec7a1afc2..1326b3c1d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java @@ -1,16 +1,34 @@ package org.joinmastodon.android.ui.photoviewer; +import android.Manifest; +import android.annotation.SuppressLint; import android.app.Activity; +import android.app.DownloadManager; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.res.ColorStateList; +import android.graphics.Insets; import android.graphics.PixelFormat; import android.graphics.Rect; import android.graphics.SurfaceTexture; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; +import android.media.AudioManager; import android.media.MediaPlayer; +import android.media.MediaScannerConnection; import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.os.SystemClock; +import android.provider.MediaStore; +import android.provider.Settings; import android.util.Log; +import android.view.DisplayCutout; import android.view.Gravity; import android.view.KeyEvent; +import android.view.MenuItem; import android.view.Surface; import android.view.TextureView; import android.view.View; @@ -19,27 +37,47 @@ import android.view.ViewTreeObserver; import android.view.WindowInsets; import android.view.WindowManager; import android.widget.FrameLayout; +import android.widget.ImageButton; import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.SeekBar; +import android.widget.TextView; +import android.widget.Toast; +import android.widget.Toolbar; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.MastodonAPIController; import org.joinmastodon.android.model.Attachment; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; +import java.io.OutputStream; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.stream.Collectors; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager2.widget.ViewPager2; +import me.grishka.appkit.imageloader.ImageCache; import me.grishka.appkit.imageloader.ViewImageLoader; import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.utils.CubicBezierInterpolator; +import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.FragmentRootLinearLayout; +import okio.BufferedSink; +import okio.Okio; +import okio.Sink; +import okio.Source; public class PhotoViewer implements ZoomPanView.Listener{ private static final String TAG="PhotoViewer"; + public static final int PERMISSION_REQUEST=926; private Activity activity; private List attachments; @@ -48,10 +86,28 @@ public class PhotoViewer implements ZoomPanView.Listener{ private Listener listener; private FrameLayout windowView; + private FragmentRootLinearLayout uiOverlay; private ViewPager2 pager; private ColorDrawable background=new ColorDrawable(0xff000000); private ArrayList players=new ArrayList<>(); private int screenOnRefCount=0; + private Toolbar toolbar; + private View toolbarWrap; + private SeekBar videoSeekBar; + private TextView videoTimeView; + private ImageButton videoPlayPauseButton; + private View videoControls; + private boolean uiVisible=true; + private AudioManager.OnAudioFocusChangeListener audioFocusListener=this::onAudioFocusChanged; + private Runnable uiAutoHider=()->{ + if(uiVisible) + toggleUI(); + }; + + private boolean videoPositionNeedsUpdating; + private Runnable videoPositionUpdater=this::updateVideoPosition; + private int videoDuration, videoInitialPosition, videoLastTimeUpdatePosition; + private long videoInitialPositionTime; public PhotoViewer(Activity activity, List attachments, int index, Listener listener){ this.activity=activity; @@ -75,7 +131,26 @@ public class PhotoViewer implements ZoomPanView.Listener{ @Override public WindowInsets dispatchApplyWindowInsets(WindowInsets insets){ - Log.w(TAG, "dispatchApplyWindowInsets() called with: insets = ["+insets+"]"); + if(Build.VERSION.SDK_INT>=29){ + DisplayCutout cutout=insets.getDisplayCutout(); + if(cutout!=null){ + // Make controls extend beneath the cutout, and replace insets to avoid cutout insets being filled with "navigation bar color" + Insets tappable=insets.getTappableElementInsets(); + int leftInset=Math.max(0, cutout.getSafeInsetLeft()-tappable.left); + int rightInset=Math.max(0, cutout.getSafeInsetRight()-tappable.right); + insets=insets.replaceSystemWindowInsets(tappable.left, tappable.top, tappable.right, tappable.bottom); + toolbarWrap.setPadding(leftInset, 0, rightInset, 0); + videoControls.setPadding(leftInset, 0, rightInset, 0); + }else{ + toolbarWrap.setPadding(0, 0, 0, 0); + videoControls.setPadding(0, 0, 0, 0); + } + } + uiOverlay.dispatchApplyWindowInsets(insets); + int bottomInset=insets.getSystemWindowInsetBottom(); + if(bottomInset>0 && bottomInsetonStartSwipeToDismissTransition(0)); + toolbar.getMenu().add(R.string.download).setIcon(R.drawable.ic_fluent_arrow_download_24_regular).setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + toolbar.setOnMenuItemClickListener(item->{ + saveCurrentFile(); + return true; + }); + uiOverlay.setAlpha(0f); + videoControls=uiOverlay.findViewById(R.id.video_player_controls); + videoSeekBar=uiOverlay.findViewById(R.id.seekbar); + videoTimeView=uiOverlay.findViewById(R.id.time); + videoPlayPauseButton=uiOverlay.findViewById(R.id.play_pause_btn); + if(attachments.get(index).type!=Attachment.Type.VIDEO){ + videoControls.setVisibility(View.GONE); + }else{ + videoDuration=(int)Math.round(attachments.get(index).getDuration()*1000); + videoLastTimeUpdatePosition=-1; + updateVideoTimeText(0); + } + WindowManager.LayoutParams wlp=new WindowManager.LayoutParams(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT); wlp.type=WindowManager.LayoutParams.TYPE_APPLICATION; wlp.flags=WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN | WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR | WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS; wlp.format=PixelFormat.TRANSLUCENT; wlp.setTitle(activity.getString(R.string.media_viewer)); + if(Build.VERSION.SDK_INT>=28) + wlp.layoutInDisplayCutoutMode=Build.VERSION.SDK_INT>=30 ? WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS : WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; windowView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); wm.addView(windowView, wlp); @@ -112,6 +219,44 @@ public class PhotoViewer implements ZoomPanView.Listener{ return true; } }); + + videoPlayPauseButton.setOnClickListener(v->{ + MediaPlayer player=findCurrentVideoPlayer(); + if(player!=null){ + if(player.isPlaying()) + pauseVideo(); + else + resumeVideo(); + hideUiDelayed(); + } + }); + videoSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener(){ + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser){ + if(fromUser){ + float p=progress/10000f; + updateVideoTimeText(Math.round(p*videoDuration)); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar){ + stopUpdatingVideoPosition(); + if(!uiVisible) // If dragging started during hide animation + toggleUI(); + windowView.removeCallbacks(uiAutoHider); + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar){ + MediaPlayer player=findCurrentVideoPlayer(); + if(player!=null){ + float progress=seekBar.getProgress()/10000f; + player.seekTo(Math.round(progress*player.getDuration())); + } + hideUiDelayed(); + } + }); } @Override @@ -127,15 +272,22 @@ public class PhotoViewer implements ZoomPanView.Listener{ @Override public void onSetBackgroundAlpha(float alpha){ background.setAlpha(Math.round(alpha*255f)); + uiOverlay.setAlpha(Math.max(0f, alpha*2f-1f)); } @Override public void onStartSwipeToDismiss(){ listener.setPhotoViewVisibility(pager.getCurrentItem(), false); + if(!uiVisible){ + windowView.setSystemUiVisibility(windowView.getSystemUiVisibility() & ~(View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_FULLSCREEN)); + }else{ + windowView.removeCallbacks(uiAutoHider); + } } @Override public void onStartSwipeToDismissTransition(float velocityY){ + pauseVideo(); // stop receiving input events to allow the user to interact with the underlying UI while the animation is still running WindowManager.LayoutParams wlp=(WindowManager.LayoutParams) windowView.getLayoutParams(); wlp.flags|=WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; @@ -163,17 +315,74 @@ public class PhotoViewer implements ZoomPanView.Listener{ @Override public void onSwipeToDismissCanceled(){ listener.setPhotoViewVisibility(pager.getCurrentItem(), true); + if(!uiVisible){ + windowView.setSystemUiVisibility(windowView.getSystemUiVisibility() | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_FULLSCREEN); + }else if(attachments.get(currentIndex).type==Attachment.Type.VIDEO){ + hideUiDelayed(); + } } @Override public void onDismissed(){ for(MediaPlayer player:players) player.release(); + if(!players.isEmpty()){ + activity.getSystemService(AudioManager.class).abandonAudioFocus(audioFocusListener); + } listener.setPhotoViewVisibility(pager.getCurrentItem(), true); wm.removeView(windowView); listener.photoViewerDismissed(); } + @Override + public void onSingleTap(){ + toggleUI(); + } + + private void toggleUI(){ + if(uiVisible){ + uiOverlay.animate() + .alpha(0f) + .setDuration(250) + .setInterpolator(CubicBezierInterpolator.DEFAULT) + .withEndAction(()->uiOverlay.setVisibility(View.GONE)) + .start(); + windowView.setSystemUiVisibility(windowView.getSystemUiVisibility() | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_FULLSCREEN); + }else{ + uiOverlay.setVisibility(View.VISIBLE); + uiOverlay.animate() + .alpha(1f) + .setDuration(300) + .setInterpolator(CubicBezierInterpolator.DEFAULT) + .start(); + windowView.setSystemUiVisibility(windowView.getSystemUiVisibility() & ~(View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_FULLSCREEN)); + if(attachments.get(currentIndex).type==Attachment.Type.VIDEO) + hideUiDelayed(5000); + } + uiVisible=!uiVisible; + } + + private void hideUiDelayed(){ + hideUiDelayed(2000); + } + + private void hideUiDelayed(long delay){ + windowView.removeCallbacks(uiAutoHider); + windowView.postDelayed(uiAutoHider, delay); + } + + private void onPageChanged(int index){ + currentIndex=index; + Attachment att=attachments.get(index); + V.setVisibilityAnimated(videoControls, att.type==Attachment.Type.VIDEO ? View.VISIBLE : View.GONE); + if(att.type==Attachment.Type.VIDEO){ + videoSeekBar.setSecondaryProgress(0); + videoDuration=(int)Math.round(att.getDuration()*1000); + videoLastTimeUpdatePosition=-1; + updateVideoTimeText(0); + } + } + /** * To be called when the list containing photo views is scrolled * @param x @@ -189,6 +398,7 @@ public class PhotoViewer implements ZoomPanView.Listener{ WindowManager.LayoutParams wlp=(WindowManager.LayoutParams) windowView.getLayoutParams(); wlp.flags|=WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON; wm.updateViewLayout(windowView, wlp); + activity.getSystemService(AudioManager.class).requestAudioFocus(audioFocusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); } screenOnRefCount++; } @@ -201,6 +411,185 @@ public class PhotoViewer implements ZoomPanView.Listener{ WindowManager.LayoutParams wlp=(WindowManager.LayoutParams) windowView.getLayoutParams(); wlp.flags&=~WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON; wm.updateViewLayout(windowView, wlp); + activity.getSystemService(AudioManager.class).abandonAudioFocus(audioFocusListener); + } + } + + public void onPause(){ + pauseVideo(); + } + + private void saveCurrentFile(){ + if(Build.VERSION.SDK_INT>=29){ + doSaveCurrentFile(); + }else{ + if(activity.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)!=PackageManager.PERMISSION_GRANTED){ + listener.onRequestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}); + }else{ + doSaveCurrentFile(); + } + } + } + + public void onRequestPermissionsResult(String[] permissions, int[] results){ + if(results[0]==PackageManager.PERMISSION_GRANTED){ + doSaveCurrentFile(); + }else if(!activity.shouldShowRequestPermissionRationale(Manifest.permission.WRITE_EXTERNAL_STORAGE)){ + new M3AlertDialogBuilder(activity) + .setTitle(R.string.permission_required) + .setMessage(R.string.storage_permission_to_download) + .setPositiveButton(R.string.open_settings, (dialog, which)->activity.startActivity(new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", activity.getPackageName(), null)))) + .setNegativeButton(R.string.cancel, null) + .show(); + } + } + + private String mimeTypeForFileName(String fileName){ + int extOffset=fileName.lastIndexOf('.'); + if(extOffset>0){ + return switch(fileName.substring(extOffset+1).toLowerCase()){ + case "jpg", "jpeg" -> "image/jpeg"; + case "png" -> "image/png"; + case "gif" -> "image/gif"; + case "webp" -> "image/webp"; + case "mp4" -> "video/mp4"; + case "webm" -> "video/webm"; + default -> null; + }; + } + return null; + } + + private OutputStream destinationStreamForFile(Attachment att) throws IOException{ + String fileName=Uri.parse(att.url).getLastPathSegment(); + if(Build.VERSION.SDK_INT>=29){ + ContentValues values=new ContentValues(); +// values.put(MediaStore.Downloads.DOWNLOAD_URI, att.url); + values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName); + values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS); + String mime=mimeTypeForFileName(fileName); + if(mime!=null) + values.put(MediaStore.MediaColumns.MIME_TYPE, mime); + ContentResolver cr=activity.getContentResolver(); + Uri itemUri=cr.insert(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), values); + return cr.openOutputStream(itemUri); + }else{ + return new FileOutputStream(new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), fileName)); + } + } + + private void doSaveCurrentFile(){ + Attachment att=attachments.get(pager.getCurrentItem()); + if(att.type==Attachment.Type.IMAGE){ + UrlImageLoaderRequest req=new UrlImageLoaderRequest(att.url); + try{ + File file=ImageCache.getInstance(activity).getFile(req); + if(file==null){ + saveViaDownloadManager(att); + return; + } + MastodonAPIController.runInBackground(()->{ + try(Source src=Okio.source(file); Sink sink=Okio.sink(destinationStreamForFile(att))){ + BufferedSink buf=Okio.buffer(sink); + buf.writeAll(src); + buf.flush(); + activity.runOnUiThread(()->Toast.makeText(activity, R.string.file_saved, Toast.LENGTH_SHORT).show()); + if(Build.VERSION.SDK_INT<29){ + String fileName=Uri.parse(att.url).getLastPathSegment(); + File dstFile=new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), fileName); + MediaScannerConnection.scanFile(activity, new String[]{dstFile.getAbsolutePath()}, new String[]{mimeTypeForFileName(fileName)}, null); + } + }catch(IOException x){ + Log.w(TAG, "doSaveCurrentFile: ", x); + activity.runOnUiThread(()->Toast.makeText(activity, R.string.error_saving_file, Toast.LENGTH_SHORT).show()); + } + }); + }catch(IOException x){ + Log.w(TAG, "doSaveCurrentFile: ", x); + Toast.makeText(activity, R.string.error_saving_file, Toast.LENGTH_SHORT).show(); + } + }else{ + saveViaDownloadManager(att); + } + } + + private void saveViaDownloadManager(Attachment att){ + DownloadManager.Request req=new DownloadManager.Request(Uri.parse(att.url)); + activity.getSystemService(DownloadManager.class).enqueue(req); + Toast.makeText(activity, R.string.downloading, Toast.LENGTH_SHORT).show(); + } + + private void onAudioFocusChanged(int change){ + if(change==AudioManager.AUDIOFOCUS_LOSS || change==AudioManager.AUDIOFOCUS_LOSS_TRANSIENT || change==AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK){ + pauseVideo(); + } + } + + private MediaPlayer findCurrentVideoPlayer(){ + RecyclerView rv=(RecyclerView) pager.getChildAt(0); + if(rv.findViewHolderForAdapterPosition(pager.getCurrentItem()) instanceof GifVViewHolder vvh && vvh.playerReady){ + return vvh.player; + } + return null; + } + + private void pauseVideo(){ + MediaPlayer player=findCurrentVideoPlayer(); + if(player==null || !player.isPlaying()) + return; + player.pause(); + videoPlayPauseButton.setImageResource(R.drawable.ic_play_24); + videoPlayPauseButton.setContentDescription(activity.getString(R.string.play)); + stopUpdatingVideoPosition(); + windowView.removeCallbacks(uiAutoHider); + } + + private void resumeVideo(){ + MediaPlayer player=findCurrentVideoPlayer(); + if(player==null || player.isPlaying()) + return; + player.start(); + videoPlayPauseButton.setImageResource(R.drawable.ic_pause_24); + videoPlayPauseButton.setContentDescription(activity.getString(R.string.pause)); + startUpdatingVideoPosition(player); + } + + private void startUpdatingVideoPosition(MediaPlayer player){ + videoInitialPosition=player.getCurrentPosition(); + videoInitialPositionTime=SystemClock.uptimeMillis(); + videoDuration=player.getDuration(); + videoPositionNeedsUpdating=true; + windowView.postOnAnimation(videoPositionUpdater); + } + + private void stopUpdatingVideoPosition(){ + videoPositionNeedsUpdating=false; + windowView.removeCallbacks(videoPositionUpdater); + } + + private String formatTime(int timeSec, boolean includeHours){ + if(includeHours) + return String.format(Locale.getDefault(), "%d:%02d:%02d", timeSec/3600, timeSec%3600/60, timeSec%60); + else + return String.format(Locale.getDefault(), "%d:%02d", timeSec/60, timeSec%60); + } + + private void updateVideoPosition(){ + if(videoPositionNeedsUpdating){ + int currentPosition=videoInitialPosition+(int)(SystemClock.uptimeMillis()-videoInitialPositionTime); + videoSeekBar.setProgress(Math.round((float)currentPosition/videoDuration*10000f)); + updateVideoTimeText(currentPosition); + windowView.postOnAnimation(videoPositionUpdater); + } + } + + @SuppressLint("SetTextI18n") + private void updateVideoTimeText(int currentPosition){ + int currentPositionSec=currentPosition/1000; + if(currentPositionSec!=videoLastTimeUpdatePosition){ + videoLastTimeUpdatePosition=currentPositionSec; + boolean includeHours=videoDuration>=3600_000; + videoTimeView.setText(formatTime(currentPositionSec, includeHours)+" / "+formatTime(videoDuration/1000, includeHours)); } } @@ -240,6 +629,7 @@ public class PhotoViewer implements ZoomPanView.Listener{ Drawable getPhotoViewCurrentDrawable(int index); void photoViewerDismissed(); + void onRequestPermissions(String[] permissions); } private class PhotoViewAdapter extends RecyclerView.Adapter{ @@ -334,13 +724,15 @@ public class PhotoViewer implements ZoomPanView.Listener{ } } - private class GifVViewHolder extends BaseHolder implements MediaPlayer.OnPreparedListener, MediaPlayer.OnErrorListener, TextureView.SurfaceTextureListener{ + private class GifVViewHolder extends BaseHolder implements MediaPlayer.OnPreparedListener, MediaPlayer.OnErrorListener, MediaPlayer.OnCompletionListener, + MediaPlayer.OnVideoSizeChangedListener, MediaPlayer.OnBufferingUpdateListener, MediaPlayer.OnInfoListener, MediaPlayer.OnSeekCompleteListener, TextureView.SurfaceTextureListener{ public TextureView textureView; public FrameLayout wrap; public MediaPlayer player; private Surface surface; private boolean playerReady; private boolean keepingScreenOn; + private ProgressBar progressBar; public GifVViewHolder(){ textureView=new TextureView(activity); @@ -348,6 +740,10 @@ public class PhotoViewer implements ZoomPanView.Listener{ zoomPanView.addView(wrap, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER)); wrap.addView(textureView); + progressBar=new ProgressBar(activity); + progressBar.setIndeterminateTintList(ColorStateList.valueOf(0xffffffff)); + zoomPanView.addView(progressBar, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER)); + textureView.setSurfaceTextureListener(this); } @@ -359,6 +755,7 @@ public class PhotoViewer implements ZoomPanView.Listener{ params.width=item.getWidth(); params.height=item.getHeight(); wrap.setBackground(listener.getPhotoViewCurrentDrawable(getAbsoluteAdapterPosition())); + progressBar.setVisibility(item.type==Attachment.Type.VIDEO ? View.VISIBLE : View.GONE); if(itemView.isAttachedToWindow()){ reset(); prepareAndStartPlayer(); @@ -369,6 +766,7 @@ public class PhotoViewer implements ZoomPanView.Listener{ public void onPrepared(MediaPlayer mp){ Log.d(TAG, "onPrepared() called with: mp = ["+mp+"]"); playerReady=true; + progressBar.setVisibility(View.GONE); if(surface!=null) startPlayer(); } @@ -398,19 +796,24 @@ public class PhotoViewer implements ZoomPanView.Listener{ private void startPlayer(){ player.setSurface(surface); - player.setLooping(true); - player.start(); if(item.type==Attachment.Type.VIDEO){ incKeepScreenOn(); keepingScreenOn=true; + if(getAbsoluteAdapterPosition()==currentIndex){ + player.start(); + startUpdatingVideoPosition(player); + hideUiDelayed(); + } }else{ keepingScreenOn=false; + player.setLooping(true); + player.start(); } } @Override public boolean onError(MediaPlayer mp, int what, int extra){ - Log.e(TAG, "gif player onError() called with: mp = ["+mp+"], what = ["+what+"], extra = ["+extra+"]"); + Log.e(TAG, "video player onError() called with: mp = ["+mp+"], what = ["+what+"], extra = ["+extra+"]"); return false; } @@ -420,6 +823,13 @@ public class PhotoViewer implements ZoomPanView.Listener{ players.add(player); player.setOnPreparedListener(this); player.setOnErrorListener(this); + player.setOnVideoSizeChangedListener(this); + if(item.type==Attachment.Type.VIDEO){ + player.setOnBufferingUpdateListener(this); + player.setOnInfoListener(this); + player.setOnSeekCompleteListener(this); + player.setOnCompletionListener(this); + } try{ player.setDataSource(activity, Uri.parse(item.url)); player.prepareAsync(); @@ -438,5 +848,53 @@ public class PhotoViewer implements ZoomPanView.Listener{ keepingScreenOn=false; } } + + @Override + public void onVideoSizeChanged(MediaPlayer mp, int width, int height){ + FrameLayout.LayoutParams params=(FrameLayout.LayoutParams) wrap.getLayoutParams(); + params.width=width; + params.height=height; + zoomPanView.updateLayout(); + } + + @Override + public void onBufferingUpdate(MediaPlayer mp, int percent){ + if(getAbsoluteAdapterPosition()==currentIndex){ + videoSeekBar.setSecondaryProgress(percent*100); + } + } + + @Override + public boolean onInfo(MediaPlayer mp, int what, int extra){ + return switch(what){ + case MediaPlayer.MEDIA_INFO_BUFFERING_START -> { + progressBar.setVisibility(View.VISIBLE); + stopUpdatingVideoPosition(); + yield true; + } + case MediaPlayer.MEDIA_INFO_BUFFERING_END -> { + progressBar.setVisibility(View.GONE); + startUpdatingVideoPosition(player); + yield true; + } + default -> false; + }; + } + + @Override + public void onSeekComplete(MediaPlayer mp){ + if(getAbsoluteAdapterPosition()==currentIndex && player.isPlaying()) + startUpdatingVideoPosition(player); + } + + @Override + public void onCompletion(MediaPlayer mp){ + videoPlayPauseButton.setImageResource(R.drawable.ic_play_24); + videoPlayPauseButton.setContentDescription(activity.getString(R.string.play)); + stopUpdatingVideoPosition(); + if(!uiVisible) + toggleUI(); + windowView.removeCallbacks(uiAutoHider); + } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/ZoomPanView.java b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/ZoomPanView.java index aa2107858..866652949 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/ZoomPanView.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/ZoomPanView.java @@ -53,6 +53,7 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS private float cropAnimationValue, rawCropAndFadeValue; private float lastFlingVelocityY; private float backgroundAlphaForTransition=1f; + private boolean forceUpdateLayout; private static final String TAG="ZoomPanView"; @@ -106,6 +107,8 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom){ super.onLayout(changed, left, top, right, bottom); + if(!changed && child!=null && !forceUpdateLayout) + return; child=getChildAt(0); if(child==null) return; @@ -120,6 +123,13 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS updateViewTransform(false); updateLimits(scale); transX=transY=0; + if(forceUpdateLayout) + forceUpdateLayout=false; + } + + public void updateLayout(){ + forceUpdateLayout=true; + requestLayout(); } private float interpolate(float a, float b, float k){ @@ -445,7 +455,8 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS @Override public boolean onSingleTapConfirmed(MotionEvent e){ - return false; + listener.onSingleTap(); + return true; } @Override @@ -589,5 +600,6 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS void onStartSwipeToDismissTransition(float velocityY); void onSwipeToDismissCanceled(); void onDismissed(); + void onSingleTap(); } } diff --git a/mastodon/src/main/res/drawable/ic_fluent_arrow_download_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_arrow_download_24_regular.xml new file mode 100644 index 000000000..40f190155 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_arrow_download_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/seekbar_video_player.xml b/mastodon/src/main/res/drawable/seekbar_video_player.xml new file mode 100644 index 000000000..c8c056870 --- /dev/null +++ b/mastodon/src/main/res/drawable/seekbar_video_player.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/seekbar_video_player_thumb.xml b/mastodon/src/main/res/drawable/seekbar_video_player_thumb.xml new file mode 100644 index 000000000..1dc6de334 --- /dev/null +++ b/mastodon/src/main/res/drawable/seekbar_video_player_thumb.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/photo_viewer_ui.xml b/mastodon/src/main/res/layout/photo_viewer_ui.xml new file mode 100644 index 000000000..97d0f8c09 --- /dev/null +++ b/mastodon/src/main/res/layout/photo_viewer_ui.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/values/strings.xml b/mastodon/src/main/res/values/strings.xml index 22365a374..64e299fc8 100644 --- a/mastodon/src/main/res/values/strings.xml +++ b/mastodon/src/main/res/values/strings.xml @@ -298,4 +298,11 @@ Header image Profile picture Reorder + Download + Permission required + The app needs access to your storage to save this file. + Open settings + Error saving file + File saved + Downloading… \ No newline at end of file