diff --git a/mastodon/src/main/AndroidManifest.xml b/mastodon/src/main/AndroidManifest.xml
index bb9b716d..8b43ff38 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 751b1a7e..f8d7c6b8 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 6fe518a7..94cf6dd7 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 ec7a1afc..1326b3c1 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 aa210785..86665294 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 00000000..40f19015
--- /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 00000000..c8c05687
--- /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 00000000..1dc6de33
--- /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 00000000..97d0f8c0
--- /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 22365a37..64e299fc 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