Photo viewer & video player UI

This commit is contained in:
Grishka 2022-04-20 15:23:52 +03:00
parent 2e1f08a096
commit 94c864c8ac
10 changed files with 620 additions and 6 deletions

View File

@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
<uses-permission android:name="${applicationId}.permission.C2D_MESSAGE"/> <uses-permission android:name="${applicationId}.permission.C2D_MESSAGE"/>
<uses-permission android:name="com.google.android.c2dm.permission.RECEIVE"/> <uses-permission android:name="com.google.android.c2dm.permission.RECEIVE"/>

View File

@ -51,6 +51,8 @@ public class AccountTimelineFragment extends StatusListFragment{
.setCallback(new SimpleCallback<>(this){ .setCallback(new SimpleCallback<>(this){
@Override @Override
public void onSuccess(List<Status> result){ public void onSuccess(List<Status> result){
if(getActivity()==null)
return;
onDataLoaded(result, !result.isEmpty()); onDataLoaded(result, !result.isEmpty());
} }
}) })

View File

@ -234,6 +234,11 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
currentPhotoViewer=null; currentPhotoViewer=null;
} }
@Override
public void onRequestPermissions(String[] permissions){
requestPermissions(permissions, PhotoViewer.PERMISSION_REQUEST);
}
private ImageStatusDisplayItem.Holder<?> findPhotoViewHolder(int index){ private ImageStatusDisplayItem.Holder<?> findPhotoViewHolder(int index){
int offset=0; int offset=0;
for(StatusDisplayItem item:displayItems){ for(StatusDisplayItem item:displayItems){
@ -562,6 +567,20 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
super.onApplyWindowInsets(insets); 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<BindableViewHolder<StatusDisplayItem>> implements ImageLoaderRecyclerAdapter{ protected class DisplayItemsAdapter extends UsableRecyclerView.Adapter<BindableViewHolder<StatusDisplayItem>> implements ImageLoaderRecyclerAdapter{
public DisplayItemsAdapter(){ public DisplayItemsAdapter(){

View File

@ -1,16 +1,34 @@
package org.joinmastodon.android.ui.photoviewer; package org.joinmastodon.android.ui.photoviewer;
import android.Manifest;
import android.annotation.SuppressLint;
import android.app.Activity; 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.PixelFormat;
import android.graphics.Rect; import android.graphics.Rect;
import android.graphics.SurfaceTexture; import android.graphics.SurfaceTexture;
import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import android.media.AudioManager;
import android.media.MediaPlayer; import android.media.MediaPlayer;
import android.media.MediaScannerConnection;
import android.net.Uri; 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.util.Log;
import android.view.DisplayCutout;
import android.view.Gravity; import android.view.Gravity;
import android.view.KeyEvent; import android.view.KeyEvent;
import android.view.MenuItem;
import android.view.Surface; import android.view.Surface;
import android.view.TextureView; import android.view.TextureView;
import android.view.View; import android.view.View;
@ -19,27 +37,47 @@ import android.view.ViewTreeObserver;
import android.view.WindowInsets; import android.view.WindowInsets;
import android.view.WindowManager; import android.view.WindowManager;
import android.widget.FrameLayout; import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView; 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.R;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.model.Attachment; 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.IOException;
import java.io.OutputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager2.widget.ViewPager2; import androidx.viewpager2.widget.ViewPager2;
import me.grishka.appkit.imageloader.ImageCache;
import me.grishka.appkit.imageloader.ViewImageLoader; import me.grishka.appkit.imageloader.ViewImageLoader;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.CubicBezierInterpolator; 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{ public class PhotoViewer implements ZoomPanView.Listener{
private static final String TAG="PhotoViewer"; private static final String TAG="PhotoViewer";
public static final int PERMISSION_REQUEST=926;
private Activity activity; private Activity activity;
private List<Attachment> attachments; private List<Attachment> attachments;
@ -48,10 +86,28 @@ public class PhotoViewer implements ZoomPanView.Listener{
private Listener listener; private Listener listener;
private FrameLayout windowView; private FrameLayout windowView;
private FragmentRootLinearLayout uiOverlay;
private ViewPager2 pager; private ViewPager2 pager;
private ColorDrawable background=new ColorDrawable(0xff000000); private ColorDrawable background=new ColorDrawable(0xff000000);
private ArrayList<MediaPlayer> players=new ArrayList<>(); private ArrayList<MediaPlayer> players=new ArrayList<>();
private int screenOnRefCount=0; 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<Attachment> attachments, int index, Listener listener){ public PhotoViewer(Activity activity, List<Attachment> attachments, int index, Listener listener){
this.activity=activity; this.activity=activity;
@ -75,7 +131,26 @@ public class PhotoViewer implements ZoomPanView.Listener{
@Override @Override
public WindowInsets dispatchApplyWindowInsets(WindowInsets insets){ 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 && bottomInset<V.dp(36)){
uiOverlay.setPadding(uiOverlay.getPaddingLeft(), uiOverlay.getPaddingTop(), uiOverlay.getPaddingRight(), V.dp(36));
}
return insets.consumeSystemWindowInsets(); return insets.consumeSystemWindowInsets();
} }
}; };
@ -84,15 +159,47 @@ public class PhotoViewer implements ZoomPanView.Listener{
pager=new ViewPager2(activity); pager=new ViewPager2(activity);
pager.setAdapter(new PhotoViewAdapter()); pager.setAdapter(new PhotoViewAdapter());
pager.setCurrentItem(index, false); pager.setCurrentItem(index, false);
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback(){
@Override
public void onPageSelected(int position){
onPageChanged(position);
}
});
windowView.addView(pager); windowView.addView(pager);
pager.setMotionEventSplittingEnabled(false); pager.setMotionEventSplittingEnabled(false);
uiOverlay=activity.getLayoutInflater().inflate(R.layout.photo_viewer_ui, windowView).findViewById(R.id.photo_viewer_overlay);
uiOverlay.setStatusBarColor(0x80000000);
uiOverlay.setNavigationBarColor(0x80000000);
toolbarWrap=uiOverlay.findViewById(R.id.toolbar_wrap);
toolbar=uiOverlay.findViewById(R.id.toolbar);
toolbar.setNavigationOnClickListener(v->onStartSwipeToDismissTransition(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); WindowManager.LayoutParams wlp=new WindowManager.LayoutParams(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT);
wlp.type=WindowManager.LayoutParams.TYPE_APPLICATION; wlp.type=WindowManager.LayoutParams.TYPE_APPLICATION;
wlp.flags=WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN | WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR wlp.flags=WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN | WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR
| WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS; | WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS;
wlp.format=PixelFormat.TRANSLUCENT; wlp.format=PixelFormat.TRANSLUCENT;
wlp.setTitle(activity.getString(R.string.media_viewer)); 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); 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); wm.addView(windowView, wlp);
@ -112,6 +219,44 @@ public class PhotoViewer implements ZoomPanView.Listener{
return true; 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 @Override
@ -127,15 +272,22 @@ public class PhotoViewer implements ZoomPanView.Listener{
@Override @Override
public void onSetBackgroundAlpha(float alpha){ public void onSetBackgroundAlpha(float alpha){
background.setAlpha(Math.round(alpha*255f)); background.setAlpha(Math.round(alpha*255f));
uiOverlay.setAlpha(Math.max(0f, alpha*2f-1f));
} }
@Override @Override
public void onStartSwipeToDismiss(){ public void onStartSwipeToDismiss(){
listener.setPhotoViewVisibility(pager.getCurrentItem(), false); 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 @Override
public void onStartSwipeToDismissTransition(float velocityY){ 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 // 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(); WindowManager.LayoutParams wlp=(WindowManager.LayoutParams) windowView.getLayoutParams();
wlp.flags|=WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; wlp.flags|=WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
@ -163,17 +315,74 @@ public class PhotoViewer implements ZoomPanView.Listener{
@Override @Override
public void onSwipeToDismissCanceled(){ public void onSwipeToDismissCanceled(){
listener.setPhotoViewVisibility(pager.getCurrentItem(), true); 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 @Override
public void onDismissed(){ public void onDismissed(){
for(MediaPlayer player:players) for(MediaPlayer player:players)
player.release(); player.release();
if(!players.isEmpty()){
activity.getSystemService(AudioManager.class).abandonAudioFocus(audioFocusListener);
}
listener.setPhotoViewVisibility(pager.getCurrentItem(), true); listener.setPhotoViewVisibility(pager.getCurrentItem(), true);
wm.removeView(windowView); wm.removeView(windowView);
listener.photoViewerDismissed(); 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 * To be called when the list containing photo views is scrolled
* @param x * @param x
@ -189,6 +398,7 @@ public class PhotoViewer implements ZoomPanView.Listener{
WindowManager.LayoutParams wlp=(WindowManager.LayoutParams) windowView.getLayoutParams(); WindowManager.LayoutParams wlp=(WindowManager.LayoutParams) windowView.getLayoutParams();
wlp.flags|=WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON; wlp.flags|=WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON;
wm.updateViewLayout(windowView, wlp); wm.updateViewLayout(windowView, wlp);
activity.getSystemService(AudioManager.class).requestAudioFocus(audioFocusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN);
} }
screenOnRefCount++; screenOnRefCount++;
} }
@ -201,6 +411,185 @@ public class PhotoViewer implements ZoomPanView.Listener{
WindowManager.LayoutParams wlp=(WindowManager.LayoutParams) windowView.getLayoutParams(); WindowManager.LayoutParams wlp=(WindowManager.LayoutParams) windowView.getLayoutParams();
wlp.flags&=~WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON; wlp.flags&=~WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON;
wm.updateViewLayout(windowView, wlp); 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); Drawable getPhotoViewCurrentDrawable(int index);
void photoViewerDismissed(); void photoViewerDismissed();
void onRequestPermissions(String[] permissions);
} }
private class PhotoViewAdapter extends RecyclerView.Adapter<BaseHolder>{ private class PhotoViewAdapter extends RecyclerView.Adapter<BaseHolder>{
@ -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 TextureView textureView;
public FrameLayout wrap; public FrameLayout wrap;
public MediaPlayer player; public MediaPlayer player;
private Surface surface; private Surface surface;
private boolean playerReady; private boolean playerReady;
private boolean keepingScreenOn; private boolean keepingScreenOn;
private ProgressBar progressBar;
public GifVViewHolder(){ public GifVViewHolder(){
textureView=new TextureView(activity); 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)); zoomPanView.addView(wrap, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER));
wrap.addView(textureView); 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); textureView.setSurfaceTextureListener(this);
} }
@ -359,6 +755,7 @@ public class PhotoViewer implements ZoomPanView.Listener{
params.width=item.getWidth(); params.width=item.getWidth();
params.height=item.getHeight(); params.height=item.getHeight();
wrap.setBackground(listener.getPhotoViewCurrentDrawable(getAbsoluteAdapterPosition())); wrap.setBackground(listener.getPhotoViewCurrentDrawable(getAbsoluteAdapterPosition()));
progressBar.setVisibility(item.type==Attachment.Type.VIDEO ? View.VISIBLE : View.GONE);
if(itemView.isAttachedToWindow()){ if(itemView.isAttachedToWindow()){
reset(); reset();
prepareAndStartPlayer(); prepareAndStartPlayer();
@ -369,6 +766,7 @@ public class PhotoViewer implements ZoomPanView.Listener{
public void onPrepared(MediaPlayer mp){ public void onPrepared(MediaPlayer mp){
Log.d(TAG, "onPrepared() called with: mp = ["+mp+"]"); Log.d(TAG, "onPrepared() called with: mp = ["+mp+"]");
playerReady=true; playerReady=true;
progressBar.setVisibility(View.GONE);
if(surface!=null) if(surface!=null)
startPlayer(); startPlayer();
} }
@ -398,19 +796,24 @@ public class PhotoViewer implements ZoomPanView.Listener{
private void startPlayer(){ private void startPlayer(){
player.setSurface(surface); player.setSurface(surface);
player.setLooping(true);
player.start();
if(item.type==Attachment.Type.VIDEO){ if(item.type==Attachment.Type.VIDEO){
incKeepScreenOn(); incKeepScreenOn();
keepingScreenOn=true; keepingScreenOn=true;
if(getAbsoluteAdapterPosition()==currentIndex){
player.start();
startUpdatingVideoPosition(player);
hideUiDelayed();
}
}else{ }else{
keepingScreenOn=false; keepingScreenOn=false;
player.setLooping(true);
player.start();
} }
} }
@Override @Override
public boolean onError(MediaPlayer mp, int what, int extra){ 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; return false;
} }
@ -420,6 +823,13 @@ public class PhotoViewer implements ZoomPanView.Listener{
players.add(player); players.add(player);
player.setOnPreparedListener(this); player.setOnPreparedListener(this);
player.setOnErrorListener(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{ try{
player.setDataSource(activity, Uri.parse(item.url)); player.setDataSource(activity, Uri.parse(item.url));
player.prepareAsync(); player.prepareAsync();
@ -438,5 +848,53 @@ public class PhotoViewer implements ZoomPanView.Listener{
keepingScreenOn=false; 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);
}
} }
} }

View File

@ -53,6 +53,7 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
private float cropAnimationValue, rawCropAndFadeValue; private float cropAnimationValue, rawCropAndFadeValue;
private float lastFlingVelocityY; private float lastFlingVelocityY;
private float backgroundAlphaForTransition=1f; private float backgroundAlphaForTransition=1f;
private boolean forceUpdateLayout;
private static final String TAG="ZoomPanView"; private static final String TAG="ZoomPanView";
@ -106,6 +107,8 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
@Override @Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom){ protected void onLayout(boolean changed, int left, int top, int right, int bottom){
super.onLayout(changed, left, top, right, bottom); super.onLayout(changed, left, top, right, bottom);
if(!changed && child!=null && !forceUpdateLayout)
return;
child=getChildAt(0); child=getChildAt(0);
if(child==null) if(child==null)
return; return;
@ -120,6 +123,13 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
updateViewTransform(false); updateViewTransform(false);
updateLimits(scale); updateLimits(scale);
transX=transY=0; transX=transY=0;
if(forceUpdateLayout)
forceUpdateLayout=false;
}
public void updateLayout(){
forceUpdateLayout=true;
requestLayout();
} }
private float interpolate(float a, float b, float k){ private float interpolate(float a, float b, float k){
@ -445,7 +455,8 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
@Override @Override
public boolean onSingleTapConfirmed(MotionEvent e){ public boolean onSingleTapConfirmed(MotionEvent e){
return false; listener.onSingleTap();
return true;
} }
@Override @Override
@ -589,5 +600,6 @@ public class ZoomPanView extends FrameLayout implements ScaleGestureDetector.OnS
void onStartSwipeToDismissTransition(float velocityY); void onStartSwipeToDismissTransition(float velocityY);
void onSwipeToDismissCanceled(); void onSwipeToDismissCanceled();
void onDismissed(); void onDismissed();
void onSingleTap();
} }
} }

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M18.25 20.5c0.414 0 0.75 0.337 0.75 0.75 0 0.415-0.336 0.75-0.75 0.75l-13 0.005c-0.414 0-0.75-0.336-0.75-0.75s0.336-0.75 0.75-0.75l13-0.004zM11.648 2.014l0.102-0.007c0.38 0 0.694 0.282 0.743 0.648L12.5 2.756 12.499 16.44l3.722-3.72c0.266-0.267 0.683-0.29 0.976-0.073l0.085 0.073c0.266 0.266 0.29 0.683 0.072 0.976l-0.073 0.084-4.997 4.997c-0.266 0.266-0.683 0.29-0.976 0.073l-0.085-0.073-5.003-4.996c-0.293-0.293-0.293-0.768 0-1.061 0.265-0.267 0.682-0.291 0.976-0.073L7.28 12.72l3.719 3.714L11 2.756c0-0.38 0.282-0.694 0.648-0.743l0.102-0.007-0.102 0.007z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:gravity="center_vertical">
<shape>
<solid android:color="@color/gray_500"/>
<corners android:radius="1dp"/>
<size android:height="2dp"/>
</shape>
</item>
<item android:gravity="center_vertical" android:id="@android:id/secondaryProgress">
<clip>
<shape android:tint="@color/gray_50">
<solid android:color="#40000000"/>
<corners android:radius="1dp"/>
<size android:height="2dp"/>
</shape>
</clip>
</item>
<item android:gravity="center_vertical" android:id="@android:id/progress">
<clip>
<shape>
<solid android:color="@color/gray_50"/>
<corners android:radius="1dp"/>
<size android:height="2dp"/>
</shape>
</clip>
</item>
</layer-list>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@color/gray_25"/>
<size android:width="18dp" android:height="18dp"/>
</shape>

View File

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<me.grishka.appkit.views.FragmentRootLinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/photo_viewer_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:theme="@style/Theme.Mastodon.Dark">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/toolbar_wrap"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:background="#80000000">
<Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?android:attr/actionBarSize"
android:elevation="0dp"
android:navigationIcon="@drawable/ic_fluent_arrow_left_24_regular"
android:navigationContentDescription="@string/back"
android:theme="@style/Theme.Mastodon.Toolbar.Profile"
android:background="@null"/>
</FrameLayout>
<RelativeLayout
android:id="@+id/video_player_controls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="#80000000">
<SeekBar
android:id="@+id/seekbar"
android:layout_width="match_parent"
android:layout_height="34dp"
android:layout_marginTop="8dp"
android:max="10000"
android:progressDrawable="@drawable/seekbar_video_player"
android:thumb="@drawable/seekbar_video_player_thumb"/>
<ImageButton
android:id="@+id/play_pause_btn"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_below="@id/seekbar"
android:layout_alignParentStart="true"
android:layout_marginLeft="8dp"
android:layout_marginRight="8dp"
android:layout_marginBottom="8dp"
android:src="@drawable/ic_pause_24"
android:tint="@color/gray_50"
android:contentDescription="@string/pause"
android:background="?android:selectableItemBackgroundBorderless"/>
<TextView
android:id="@+id/time"
android:layout_width="wrap_content"
android:layout_height="24dp"
android:layout_below="@id/seekbar"
android:layout_alignParentEnd="true"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:gravity="center_vertical"
android:textAppearance="@style/m3_body_large"
android:textColor="#fff"
tools:text="1:23 / 4:56"/>
</RelativeLayout>
</FrameLayout>
</me.grishka.appkit.views.FragmentRootLinearLayout>

View File

@ -298,4 +298,11 @@
<string name="profile_header">Header image</string> <string name="profile_header">Header image</string>
<string name="profile_picture">Profile picture</string> <string name="profile_picture">Profile picture</string>
<string name="reorder">Reorder</string> <string name="reorder">Reorder</string>
<string name="download">Download</string>
<string name="permission_required">Permission required</string>
<string name="storage_permission_to_download">The app needs access to your storage to save this file.</string>
<string name="open_settings">Open settings</string>
<string name="error_saving_file">Error saving file</string>
<string name="file_saved">File saved</string>
<string name="downloading">Downloading…</string>
</resources> </resources>