diff --git a/app/build.gradle b/app/build.gradle index 47b43011a..662589293 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -123,6 +123,15 @@ android { testOptions { unitTests { includeAndroidResources true + returnDefaultValues = true + all { + jvmArgs = [ + '--add-opens=java.base/java.lang=ALL-UNNAMED', + '--add-opens=java.base/java.util=ALL-UNNAMED', + '--add-opens=java.base/java.lang.reflect=ALL-UNNAMED', + '--add-opens=java.base/java.util.regex=ALL-UNNAMED' + ] + } } } @@ -317,8 +326,13 @@ dependencies { /** Testing **/ testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:5.12.0' + testImplementation 'org.mockito:mockito-inline:5.2.0' + testImplementation 'org.robolectric:robolectric:4.13' + testImplementation 'androidx.test:core:1.6.1' testImplementation 'org.hamcrest:hamcrest:2.2' testImplementation 'org.powermock:powermock-module-junit4:2.0.9' + testImplementation 'org.powermock:powermock-api-mockito2:2.0.9' } spotless { diff --git a/app/src/main/java/me/edgan/redditslide/Activities/AlbumPager.java b/app/src/main/java/me/edgan/redditslide/Activities/AlbumPager.java index 6c70065f7..068138a14 100644 --- a/app/src/main/java/me/edgan/redditslide/Activities/AlbumPager.java +++ b/app/src/main/java/me/edgan/redditslide/Activities/AlbumPager.java @@ -14,7 +14,6 @@ import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; -import android.os.Handler; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; @@ -262,24 +261,6 @@ public void run() { // Reset the currently playing position Gif.currentlyPlayingPosition = -1; - // Initialize the correct position after a delay - new Handler().postDelayed(() -> { - if (!isFinishing()) { - int position = p.getCurrentItem(); - int adjustedPosition = position; - if (SettingValues.oldSwipeMode && position > 0) { - adjustedPosition = position - 1; - } - - if (images != null && !images.isEmpty() && adjustedPosition >= 0 && adjustedPosition < images.size()) { - if (images.get(adjustedPosition).isAnimated()) { - LogUtil.v("Setting initial playing position to " + adjustedPosition); - Gif.currentlyPlayingPosition = adjustedPosition; - } - } - } - }, 100); - findViewById(R.id.grid) .setOnClickListener( new View.OnClickListener() { @@ -349,16 +330,18 @@ public void onPageScrolled( @Override public void onPageSelected(int position) { - // When a page is selected, explicitly tell all Gif fragments to check their visibility if (adapter != null && adapter.getCount() > 0) { int adjustedPosition = position; if (SettingValues.oldSwipeMode && position > 0) { adjustedPosition = position - 1; } - - // Update the currently playing position in the Gif class - if (images.get(adjustedPosition).isAnimated()) { - Gif.currentlyPlayingPosition = adjustedPosition; + FragmentManager fm = getSupportFragmentManager(); + for (int i = 0; i < adapter.getCount(); i++) { + String tag = "android:switcher:" + R.id.images_horizontal + ":" + i; + Fragment frag = fm.findFragmentByTag(tag); + if (frag instanceof Gif) { + ((Gif)frag).setPlaybackActive(i == adjustedPosition); + } } } } @@ -393,24 +376,18 @@ public Fragment getItem(int i) { if (i == 0) { return new BlankFragment(); } - i--; } - Image current = images.get(i); - Fragment f; - if (current.isAnimated()) { f = new Gif(); } else { f = new ImageFullNoSubmission(); } - Bundle args = new Bundle(); args.putInt("page", i); f.setArguments(args); - return f; } @@ -434,32 +411,41 @@ public static class Gif extends Fragment { ProgressBar loader; // Static tracking of which fragment is currently playing - private static int currentlyPlayingPosition = -1; + // Use package-private for access from AlbumPager + static int currentlyPlayingPosition = -1; + + public void setPlaybackActive(boolean active) { + if (gif != null && gif instanceof ExoVideoView) { + if (active) { + ((ExoVideoView) gif).play(); + gif.setVisibility(View.VISIBLE); + } else { + ((ExoVideoView) gif).stop(); // Stop and release resources + gif.setVisibility(View.GONE); + } + } + } @Override public void setUserVisibleHint(boolean isVisibleToUser) { super.setUserVisibleHint(isVisibleToUser); + // No-op: playback is now controlled by onPageSelected + } - // If fragment is becoming visible - if (isVisibleToUser) { - // Record this position as the currently playing fragment - currentlyPlayingPosition = i; - LogUtil.v("Gif fragment " + i + " becoming visible, setting as current"); - - // Only start playback if view is created - if (gif != null && gif instanceof ExoVideoView) { - LogUtil.v("Playing gif at position " + i); - ((ExoVideoView) gif).play(); - gif.setVisibility(View.VISIBLE); - } - } else { - // If this fragment is becoming invisible and it's the one that was playing - if (currentlyPlayingPosition == i && gif != null && gif instanceof ExoVideoView) { - LogUtil.v("Pausing gif at position " + i); - ((ExoVideoView) gif).pause(); - gif.setVisibility(View.GONE); + private int getCurrentPagerPosition() { + Activity activity = getActivity(); + if (activity instanceof AlbumPager) { + ViewPager pager = activity.findViewById(R.id.images_horizontal); + if (pager != null) { + int pos = pager.getCurrentItem(); + if (SettingValues.oldSwipeMode && pos > 0) { + return pos - 1; + } else { + return pos; + } } } + return -1; } @Override @@ -498,6 +484,16 @@ public View onCreateView( v.attachHqButton(hqButton); } + ImageView speedButton = rootView.findViewById(R.id.speed); + if (speedButton != null) { + if (((AlbumPager) getActivity()).images.get(i).isAnimated()) { + speedButton.setVisibility(View.VISIBLE); + v.attachSpeedButton(speedButton, getActivity()); + } else { + speedButton.setVisibility(View.GONE); + } + } + final String url = ((AlbumPager) getActivity()).images.get(i).getImageUrl(); // Important: Always start with autostart=false @@ -514,41 +510,6 @@ public View onCreateView( getActivity().getIntent().getStringExtra(EXTRA_SUBMISSION_TITLE)) .execute(url); - // Add a delayed check to control playback once the UI is fully set up - // This helps overcome timing issues with fragment visibility - new Handler().postDelayed(() -> { - if (getActivity() != null && !getActivity().isFinishing() && gif != null) { - // Initialize the current playing position if it's not set yet - if (currentlyPlayingPosition == -1 && getActivity() instanceof AlbumPager) { - ViewPager pager = getActivity().findViewById(R.id.images_horizontal); - if (pager != null) { - int currentItem = pager.getCurrentItem(); - int adjustedPosition = currentItem; - if (SettingValues.oldSwipeMode && currentItem > 0) { - adjustedPosition = currentItem - 1; - } - - // This is needed to handle the initial case - if (i == adjustedPosition) { - LogUtil.v("Initial load: setting position " + i + " as current"); - currentlyPlayingPosition = i; - } - } - } - - // Check if this is the current playing position - if (getUserVisibleHint() && (currentlyPlayingPosition == i || currentlyPlayingPosition == -1)) { - LogUtil.v("Playing gif at position " + i + " after delay"); - ((ExoVideoView) gif).play(); - // Ensure we update the playing position - currentlyPlayingPosition = i; - } else { - LogUtil.v("Not playing gif at position " + i + " after delay"); - ((ExoVideoView) gif).pause(); - } - } - }, 500); // Increased delay to give more time for ViewPager to settle - rootView.findViewById(R.id.more) .setOnClickListener( new View.OnClickListener() { @@ -574,6 +535,23 @@ public void onClick(View v) { if (!SettingValues.imageDownloadButton) { rootView.findViewById(R.id.save).setVisibility(View.INVISIBLE); } + + // Add comment button logic + View comments = rootView.findViewById(R.id.comments); + if (comments != null) { + if (getActivity().getIntent().hasExtra(MediaView.SUBMISSION_URL)) { + comments.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + getActivity().finish(); + SubmissionsView.datachanged(adapterPosition); + } + }); + } else { + comments.setVisibility(View.GONE); + } + } + return rootView; } @@ -587,8 +565,8 @@ public void onCreate(Bundle savedInstanceState) { @Override public void onResume() { super.onResume(); - // When fragment resumes, check if it should be playing - if (getUserVisibleHint() && currentlyPlayingPosition == i && + // Only play if this fragment is visible and is the current playing position + if (getUserVisibleHint() && getCurrentPagerPosition() == i && gif != null && gif instanceof ExoVideoView) { ((ExoVideoView) gif).play(); } @@ -597,7 +575,6 @@ public void onResume() { @Override public void onPause() { super.onPause(); - // Always pause when the fragment is paused if (gif != null && gif instanceof ExoVideoView) { ((ExoVideoView) gif).pause(); } @@ -606,7 +583,6 @@ public void onPause() { @Override public void onDestroyView() { super.onDestroyView(); - // Clean up if (gif != null && gif instanceof ExoVideoView) { ((ExoVideoView) gif).pause(); } @@ -749,30 +725,58 @@ public View onCreateView( loadImage(rootView, this, url, ((AlbumPager) getActivity()).images.size() == 1); } - { - rootView.findViewById(R.id.more) - .setOnClickListener( - new View.OnClickListener() { - @Override - public void onClick(View v) { - ((AlbumPager) getActivity()) - .showBottomSheetImage(url, false, i); - } - }); - { - rootView.findViewById(R.id.save) - .setOnClickListener( - new View.OnClickListener() { - - @Override - public void onClick(View v2) { - ((AlbumPager) getActivity()) - .doImageSave(false, url, i); - } - }); - if (!SettingValues.imageDownloadButton) { - rootView.findViewById(R.id.save).setVisibility(View.INVISIBLE); - } + View more = rootView.findViewById(R.id.more); + if (more != null) { + more.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View v) { + ((AlbumPager) getActivity()) + .showBottomSheetImage(url, false, i); + } + }); + } + View save = rootView.findViewById(R.id.save); + if (save != null) { + save.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View v2) { + ((AlbumPager) getActivity()) + .doImageSave(false, url, i); + } + }); + if (!SettingValues.imageDownloadButton) { + save.setVisibility(View.INVISIBLE); + } + } + View panel = rootView.findViewById(R.id.panel); + if (panel != null) { + panel.setVisibility(View.GONE); + } + View margin = rootView.findViewById(R.id.margin); + if (margin != null) { + margin.setPadding(0, 0, 0, 0); + } + View hq = rootView.findViewById(R.id.hq); + if (hq != null) { + hq.setVisibility(View.GONE); + } + View comments = rootView.findViewById(R.id.comments); + if (getActivity().getIntent().hasExtra(MediaView.SUBMISSION_URL)) { + if (comments != null) { + comments.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View v) { + getActivity().finish(); + SubmissionsView.datachanged(adapterPosition); + } + }); + } + } else { + if (comments != null) { + comments.setVisibility(View.GONE); } } { @@ -842,6 +846,10 @@ public void onClick(View v) { } }); } + View mute = rootView.findViewById(R.id.mute); + if (mute != null) { + mute.setVisibility(View.GONE); + } if (lq) { rootView.findViewById(R.id.hq) .setOnClickListener( @@ -860,20 +868,6 @@ public void onClick(View v) { } else { rootView.findViewById(R.id.hq).setVisibility(View.GONE); } - - if (getActivity().getIntent().hasExtra(MediaView.SUBMISSION_URL)) { - rootView.findViewById(R.id.comments) - .setOnClickListener( - new View.OnClickListener() { - @Override - public void onClick(View v) { - getActivity().finish(); - SubmissionsView.datachanged(adapterPosition); - } - }); - } else { - rootView.findViewById(R.id.comments).setVisibility(View.GONE); - } } return rootView; } diff --git a/app/src/main/java/me/edgan/redditslide/Activities/MediaView.java b/app/src/main/java/me/edgan/redditslide/Activities/MediaView.java index 22324efdd..79364c0a3 100644 --- a/app/src/main/java/me/edgan/redditslide/Activities/MediaView.java +++ b/app/src/main/java/me/edgan/redditslide/Activities/MediaView.java @@ -18,7 +18,6 @@ import android.os.Handler; import android.util.Log; import android.view.View; -import android.view.WindowManager; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.ProgressBar; @@ -444,9 +443,9 @@ public void onCreate(Bundle savedInstanceState) { } setContentView(R.layout.activity_media); - - // Keep the screen on - getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + // Hide speed button by default + ImageView speedBtn = (ImageView) findViewById(R.id.speed); + if (speedBtn != null) speedBtn.setVisibility(View.GONE); final String firstUrl = getIntent().getExtras().getString(EXTRA_DISPLAY_URL, ""); contentUrl = getIntent().getExtras().getString(EXTRA_URL); @@ -624,8 +623,12 @@ public void onClick(View v) { ((TextView) findViewById(R.id.size)), subreddit, submissionTitle); + // Show and attach speed button for GIFs + ImageView speedBtn = (ImageView) findViewById(R.id.speed); + if (speedBtn != null) speedBtn.setVisibility(View.VISIBLE); videoView.attachMuteButton((ImageView) findViewById(R.id.mute)); videoView.attachHqButton((ImageView) findViewById(R.id.hq)); + videoView.attachSpeedButton(speedBtn, this); gif.execute(dat); findViewById(R.id.more) .setOnClickListener( @@ -1031,7 +1034,7 @@ public void onAnimationUpdate( @Override public void onLoadingStarted(String imageUri, View view) { imageShown = true; - size.setVisibility(View.VISIBLE); + if (size != null) size.setVisibility(View.VISIBLE); } @Override @@ -1045,7 +1048,7 @@ public void onLoadingFailed( public void onLoadingComplete( String imageUri, View view, Bitmap loadedImage) { imageShown = true; - size.setVisibility(View.GONE); + if (size != null) size.setVisibility(View.GONE); File f = ((Reddit) getApplicationContext()) @@ -1137,10 +1140,10 @@ public void onLoadingCancelled(String imageUri, View view) { @Override public void onProgressUpdate( String imageUri, View view, int current, int total) { - size.setText(FileUtil.readableFileSize(total)); - - ((ProgressBar) findViewById(R.id.progress)) - .setProgress(Math.round(100.0f * current / total)); + TextView size = (TextView) findViewById(R.id.size); + if (size != null) { + size.setText(String.format("%d%%", (int) (100.0 * current / total))); + } } }); } diff --git a/app/src/main/java/me/edgan/redditslide/Activities/RedditGallery.java b/app/src/main/java/me/edgan/redditslide/Activities/RedditGallery.java index dccdfcc0c..bc7fb33e5 100644 --- a/app/src/main/java/me/edgan/redditslide/Activities/RedditGallery.java +++ b/app/src/main/java/me/edgan/redditslide/Activities/RedditGallery.java @@ -395,6 +395,16 @@ public static class Gif extends Fragment { private View gifView; private ProgressBar loader; + // Helper method to get adapter position from activity + private int getAdapterPositionFromActivity(android.app.Activity activity) { + if (activity instanceof RedditGallery) { + return ((RedditGallery) activity).adapterPosition; + } else if (activity instanceof RedditGalleryPager) { + return activity.getIntent().getIntExtra(MediaView.ADAPTER_POSITION, -1); + } + return -1; + } + // Override this in subclasses to provide appropriate parent protected GalleryParent getGalleryParent() { return (RedditGallery) getActivity(); @@ -482,6 +492,29 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa } rootView.findViewById(R.id.mute).setVisibility(View.GONE); rootView.findViewById(R.id.hq).setVisibility(View.GONE); + + ImageView speedButton = rootView.findViewById(R.id.speed); + if (speedButton != null) { + if (current != null && current.isAnimated()) { + speedButton.setVisibility(View.VISIBLE); + exoVideoView.attachSpeedButton(speedButton, getActivity()); + } else { + speedButton.setVisibility(View.GONE); + } + } + + // Add comment button logic + View comments = rootView.findViewById(R.id.comments); + if (comments != null) { + if (getActivity().getIntent().hasExtra(MediaView.SUBMISSION_URL)) { + comments.setOnClickListener(v -> { + getActivity().finish(); + SubmissionsView.datachanged(getAdapterPositionFromActivity(getActivity())); + }); + } else { + comments.setVisibility(View.GONE); + } + } } } diff --git a/app/src/main/java/me/edgan/redditslide/Activities/RedditGalleryPager.java b/app/src/main/java/me/edgan/redditslide/Activities/RedditGalleryPager.java index 3e8a088d3..02bea6abf 100644 --- a/app/src/main/java/me/edgan/redditslide/Activities/RedditGalleryPager.java +++ b/app/src/main/java/me/edgan/redditslide/Activities/RedditGalleryPager.java @@ -468,44 +468,62 @@ public View onCreateView( ((RedditGalleryPager) getActivity()).images.size() == 1); } - rootView.findViewById(R.id.more) - .setOnClickListener( + View more = rootView.findViewById(R.id.more); + if (more != null) { + more.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View v) { + ((RedditGalleryPager) getActivity()) + .showBottomSheetImage(url, false, i); + } + }); + } + View save = rootView.findViewById(R.id.save); + if (save != null) { + save.setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View v2) { + ((RedditGalleryPager) getActivity()).doImageSave(false, url, i); + } + }); + if (!SettingValues.imageDownloadButton) { + save.setVisibility(View.INVISIBLE); + } + } + View panel = rootView.findViewById(R.id.panel); + if (panel != null) { + panel.setVisibility(View.GONE); + } + View margin = rootView.findViewById(R.id.margin); + if (margin != null) { + margin.setPadding(0, 0, 0, 0); + } + View hq = rootView.findViewById(R.id.hq); + if (hq != null) { + hq.setVisibility(View.GONE); + } + View mute = rootView.findViewById(R.id.mute); + if (mute != null) { + mute.setVisibility(View.GONE); + } + View comments = rootView.findViewById(R.id.comments); + if (getActivity().getIntent().hasExtra(MediaView.SUBMISSION_URL)) { + if (comments != null) { + comments.setOnClickListener( new View.OnClickListener() { @Override public void onClick(View v) { - ((RedditGalleryPager) getActivity()) - .showBottomSheetImage(url, false, i); - } - }); - rootView.findViewById(R.id.save) - .setOnClickListener( - new View.OnClickListener() { - @Override - public void onClick(View v2) { - ((RedditGalleryPager) getActivity()).doImageSave(false, url, i); + getActivity().finish(); + SubmissionsView.datachanged(adapterPosition); } }); - if (!SettingValues.imageDownloadButton) { - rootView.findViewById(R.id.save).setVisibility(View.INVISIBLE); - } - - rootView.findViewById(R.id.panel).setVisibility(View.GONE); - (rootView.findViewById(R.id.margin)).setPadding(0, 0, 0, 0); - - rootView.findViewById(R.id.hq).setVisibility(View.GONE); - - if (getActivity().getIntent().hasExtra(MediaView.SUBMISSION_URL)) { - rootView.findViewById(R.id.comments) - .setOnClickListener( - new View.OnClickListener() { - @Override - public void onClick(View v) { - getActivity().finish(); - SubmissionsView.datachanged(adapterPosition); - } - }); + } } else { - rootView.findViewById(R.id.comments).setVisibility(View.GONE); + if (comments != null) { + comments.setVisibility(View.GONE); + } } return rootView; } diff --git a/app/src/main/java/me/edgan/redditslide/Activities/Tumblr.java b/app/src/main/java/me/edgan/redditslide/Activities/Tumblr.java index b9372f212..e2a543a90 100644 --- a/app/src/main/java/me/edgan/redditslide/Activities/Tumblr.java +++ b/app/src/main/java/me/edgan/redditslide/Activities/Tumblr.java @@ -222,7 +222,7 @@ public View onCreateView( new LoadIntoRecycler(((Tumblr) getActivity()).url, getActivity()) .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); ((Tumblr) getActivity()).mToolbar = rootView.findViewById(R.id.toolbar); - ((Tumblr) getActivity()).mToolbar.setTitle(R.string.type_album); + ((Tumblr) getActivity()).mToolbar.setTitle(R.string.type_tumblr); ToolbarColorizeHelper.colorizeToolbar( ((Tumblr) getActivity()).mToolbar, Color.WHITE, (getActivity())); ((Tumblr) getActivity()).setSupportActionBar(((Tumblr) getActivity()).mToolbar); diff --git a/app/src/main/java/me/edgan/redditslide/Activities/TumblrPager.java b/app/src/main/java/me/edgan/redditslide/Activities/TumblrPager.java index a2acf6068..9d7060a84 100644 --- a/app/src/main/java/me/edgan/redditslide/Activities/TumblrPager.java +++ b/app/src/main/java/me/edgan/redditslide/Activities/TumblrPager.java @@ -11,6 +11,7 @@ import android.graphics.drawable.Drawable; import android.os.AsyncTask; import android.os.Bundle; +import android.os.SystemClock; import android.util.Log; import android.view.LayoutInflater; import android.view.Menu; @@ -24,6 +25,7 @@ import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.ProgressBar; +import android.widget.RelativeLayout; import android.widget.TextView; import androidx.annotation.NonNull; @@ -63,6 +65,7 @@ import me.edgan.redditslide.util.BlendModeUtil; import me.edgan.redditslide.util.DialogUtil; import me.edgan.redditslide.util.FileUtil; +import me.edgan.redditslide.util.GifDrawable; import me.edgan.redditslide.util.GifUtils; import me.edgan.redditslide.util.ImageSaveUtils; import me.edgan.redditslide.util.LinkUtil; @@ -70,6 +73,10 @@ import me.edgan.redditslide.util.ShareUtil; import me.edgan.redditslide.util.SubmissionParser; +import java.io.File; +import android.graphics.Movie; +import android.net.Uri; + import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; @@ -160,7 +167,7 @@ public void onCreate(Bundle savedInstanceState) { getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); mToolbar = (Toolbar) findViewById(R.id.toolbar); - mToolbar.setTitle(R.string.type_album); + mToolbar.setTitle(R.string.type_tumblr); ToolbarColorizeHelper.colorizeToolbar(mToolbar, Color.WHITE, this); setSupportActionBar(mToolbar); getSupportActionBar().setDisplayHomeAsUpEnabled(true); @@ -394,33 +401,117 @@ public void setUserVisibleHint(boolean isVisibleToUser) { } @Override - public View onCreateView( - LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - rootView = - (ViewGroup) - inflater.inflate(R.layout.submission_gifcard_album, container, false); + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + Bundle bundle = this.getArguments(); + final int i = bundle.getInt("page", 0); + + rootView = (ViewGroup) inflater.inflate(R.layout.submission_gifcard_album, container, false); loader = rootView.findViewById(R.id.gifprogress); + final View videoView = rootView.findViewById(R.id.gif); // This is an ExoVideoView + + final String url = ((TumblrPager) getActivity()).images.get(i).getOriginalSize().getUrl(); + + if (url != null && url.toLowerCase().endsWith(".gif")) { + videoView.setVisibility(View.GONE); // Hide ExoVideoView + View playButton = rootView.findViewById(R.id.playbutton); + if (playButton != null) { + playButton.setVisibility(View.GONE); + } + + final ImageView imageView = new ImageView(getContext()); + imageView.setScaleType(ImageView.ScaleType.FIT_CENTER); + RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams( + RelativeLayout.LayoutParams.MATCH_PARENT, + RelativeLayout.LayoutParams.MATCH_PARENT); + layoutParams.addRule(RelativeLayout.CENTER_IN_PARENT, RelativeLayout.TRUE); + imageView.setLayoutParams(layoutParams); + + RelativeLayout imageArea = rootView.findViewById(R.id.imagearea); + imageArea.addView(imageView); // Add ImageView to the layout + + loader.setVisibility(View.VISIBLE); + + GifUtils.downloadGif(url, new GifUtils.GifDownloadCallback() { + @Override + public void onGifDownloaded(File gifFile) { + if (getActivity() == null) return; + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + loader.setVisibility(View.GONE); + Movie movie = Movie.decodeFile(gifFile.getAbsolutePath()); + if (movie != null) { + GifDrawable gifDrawable = new GifDrawable(movie, new Drawable.Callback() { + @Override + public void invalidateDrawable(@NonNull Drawable who) { + imageView.invalidate(); + } + + @Override + public void scheduleDrawable(@NonNull Drawable who, @NonNull Runnable what, long when) { + imageView.postDelayed(what, when - SystemClock.uptimeMillis()); + } + + @Override + public void unscheduleDrawable(@NonNull Drawable who, @NonNull Runnable what) { + imageView.removeCallbacks(what); + } + }); + imageView.setImageDrawable(gifDrawable); + gifDrawable.start(); + } else { + // Optionally, show an error or fallback + Log.e(TAG, "Failed to decode GIF: " + url); + if (videoView instanceof ExoVideoView) { + ((ExoVideoView) videoView).setVideoURI(Uri.parse(url), ExoVideoView.VideoType.STANDARD, null); // Fallback to ExoVideoView if Movie decoding fails + ((ExoVideoView) videoView).play(); + videoView.setVisibility(View.VISIBLE); + imageView.setVisibility(View.GONE); + } + } + } + }); + } + + @Override + public void onGifDownloadFailed(Exception e) { + if (getActivity() == null) return; + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + loader.setVisibility(View.GONE); + Log.e(TAG, "Failed to download GIF: " + url, e); + // Fallback to trying with ExoVideoView or show error + if (videoView instanceof ExoVideoView) { + ((ExoVideoView) videoView).setVideoURI(Uri.parse(url), ExoVideoView.VideoType.STANDARD, null); + ((ExoVideoView) videoView).play(); + videoView.setVisibility(View.VISIBLE); + imageView.setVisibility(View.GONE); + } + } + }); + } + }, getContext(), null); // Pass null for submissionTitle if not available/needed here + + } else { // Not a direct .gif URL, or URL is null, proceed with ExoVideoView + gif = rootView.findViewById(R.id.gif); + gif.setVisibility(View.VISIBLE); + final ExoVideoView v = (ExoVideoView) gif; + v.clearFocus(); + + new GifUtils.AsyncLoadGif( + getActivity(), + rootView.findViewById(R.id.gif), // This is the ExoVideoView + loader, + null, // placeholder + false, // closeIfNull + true, // autostart + rootView.findViewById(R.id.size), + ((TumblrPager) getActivity()).subreddit, + null) // Pass null for submissionTitle + .execute(url); + } - gif = rootView.findViewById(R.id.gif); - - gif.setVisibility(View.VISIBLE); - final ExoVideoView v = (ExoVideoView) gif; - v.clearFocus(); - - final String url = - ((TumblrPager) getActivity()).images.get(i).getOriginalSize().getUrl(); - - new GifUtils.AsyncLoadGif( - getActivity(), - rootView.findViewById(R.id.gif), - loader, - null, // placeholder - false, // closeIfNull - true, // autostart - rootView.findViewById(R.id.size), - ((TumblrPager) getActivity()).subreddit, - null) - .execute(url); rootView.findViewById(R.id.more) .setOnClickListener( new View.OnClickListener() { diff --git a/app/src/main/java/me/edgan/redditslide/Adapters/AlbumView.java b/app/src/main/java/me/edgan/redditslide/Adapters/AlbumView.java index deb1d2f94..594865816 100644 --- a/app/src/main/java/me/edgan/redditslide/Adapters/AlbumView.java +++ b/app/src/main/java/me/edgan/redditslide/Adapters/AlbumView.java @@ -242,6 +242,10 @@ public void onClick(View view) { holder.saveButton.setVisibility(View.GONE); holder.moreButton.setVisibility(View.GONE); + View commentsButton = holder.rootView.findViewById(R.id.comments); + if (commentsButton != null) { + commentsButton.setVisibility(View.GONE); + } holder.muteButton.setVisibility(View.GONE); holder.hqButton.setVisibility(View.GONE); diff --git a/app/src/main/java/me/edgan/redditslide/Adapters/RedditGalleryView.java b/app/src/main/java/me/edgan/redditslide/Adapters/RedditGalleryView.java index 5f5af7af5..6f5f35589 100644 --- a/app/src/main/java/me/edgan/redditslide/Adapters/RedditGalleryView.java +++ b/app/src/main/java/me/edgan/redditslide/Adapters/RedditGalleryView.java @@ -288,6 +288,10 @@ public void onClick(View v) { holder.saveButton.setVisibility(View.GONE); holder.moreButton.setVisibility(View.GONE); + View commentsButton = holder.rootView.findViewById(R.id.comments); + if (commentsButton != null) { + commentsButton.setVisibility(View.GONE); + } // Hide the "save" button if user preference is off if (!SettingValues.imageDownloadButton) { diff --git a/app/src/main/java/me/edgan/redditslide/Authentication.java b/app/src/main/java/me/edgan/redditslide/Authentication.java index f29275f44..babd035fb 100644 --- a/app/src/main/java/me/edgan/redditslide/Authentication.java +++ b/app/src/main/java/me/edgan/redditslide/Authentication.java @@ -148,7 +148,7 @@ protected Void doInBackground(Void... params) { .edit() .putLong( "expires", - Calendar.getInstance().getTimeInMillis() + 3000000) + Calendar.getInstance().getTimeInMillis() + Constants.EXPIRES_VALUE) .commit(); } authentication @@ -182,7 +182,7 @@ protected Void doInBackground(Void... params) { .edit() .putLong( "expires", - Calendar.getInstance().getTimeInMillis() + 3000000) + Calendar.getInstance().getTimeInMillis() + Constants.EXPIRES_VALUE) .commit(); authentication .edit() @@ -294,7 +294,7 @@ public static void doVerify( .edit() .putLong( "expires", - Calendar.getInstance().getTimeInMillis() + 3000000) + Calendar.getInstance().getTimeInMillis() + Constants.EXPIRES_VALUE) .apply(); } } @@ -343,7 +343,7 @@ public static void doVerify( authData = reddit.getOAuthHelper().easyAuth(fcreds); authentication .edit() - .putLong("expires", Calendar.getInstance().getTimeInMillis() + 3000000) + .putLong("expires", Calendar.getInstance().getTimeInMillis() + Constants.EXPIRES_VALUE) .apply(); authentication .edit() diff --git a/app/src/main/java/me/edgan/redditslide/Constants.java b/app/src/main/java/me/edgan/redditslide/Constants.java index 840bf76b6..6e1de472e 100644 --- a/app/src/main/java/me/edgan/redditslide/Constants.java +++ b/app/src/main/java/me/edgan/redditslide/Constants.java @@ -32,6 +32,9 @@ public class Constants { public static final int PTR_OFFSET_BOTTOM = DisplayUtil.dpToPxVertical(18); + // 1000 * 60 * 50 = 50 minutes in milliseconds + public static final int EXPIRES_VALUE = 3000000; + /** * Drawer swipe edge (navdrawer). The higher the value, the more sensitive the navdrawer swipe * area becomes. This is a percentage of the screen width. diff --git a/app/src/main/java/me/edgan/redditslide/Fragments/SubmissionsView.java b/app/src/main/java/me/edgan/redditslide/Fragments/SubmissionsView.java index 45b73adc2..1988cf2fc 100644 --- a/app/src/main/java/me/edgan/redditslide/Fragments/SubmissionsView.java +++ b/app/src/main/java/me/edgan/redditslide/Fragments/SubmissionsView.java @@ -585,19 +585,12 @@ public void onCreate(Bundle savedInstanceState) { public void onResume() { super.onResume(); if (adapter != null && adapterPosition > 0 && currentPosition == adapterPosition) { - if (adapter.dataSet.getPosts().size() >= adapterPosition - 1 - && adapter.dataSet.getPosts().get(adapterPosition - 1) == currentSubmission) { - // Use a Handler to post the click action to the main thread's message queue - // This prevents the "FragmentManager is already executing transactions" error - new Handler().post(new Runnable() { - @Override - public void run() { - if (isAdded() && adapter != null) { - adapter.performClick(adapterPosition); - } - } - }); - adapterPosition = -1; + List postsList = adapter.dataSet.getPosts(); + if (postsList != null && !postsList.isEmpty() && (adapterPosition - 1) >= 0 && (adapterPosition - 1) < postsList.size()) { + if (postsList.get(adapterPosition - 1) == currentSubmission) { + adapter.performClick(adapterPosition); + adapterPosition = -1; + } } } } diff --git a/app/src/main/java/me/edgan/redditslide/OpenRedditLink.java b/app/src/main/java/me/edgan/redditslide/OpenRedditLink.java index a5198e199..520f4969f 100644 --- a/app/src/main/java/me/edgan/redditslide/OpenRedditLink.java +++ b/app/src/main/java/me/edgan/redditslide/OpenRedditLink.java @@ -450,6 +450,14 @@ public static RedditLinkType getRedditLinkType(@NonNull Uri uri) { return RedditLinkType.SHORTENED; } + // Handle cases like reddit.com/comments/xxxx as shortened links + if (host.equals("reddit.com") && path.matches("(?i)/comments/[^/]+/?")) { + boolean isSubmissionOrComment = path.matches("(?i)/(?:r|u(?:ser)?)/[^/]+/comments/.*"); + if (!isSubmissionOrComment) { + return RedditLinkType.SHORTENED; + } + } + if (path.matches("(?i)/live/[^/]*")) { return RedditLinkType.LIVE; } else if (path.matches("(?i)/message/compose.*")) { diff --git a/app/src/main/java/me/edgan/redditslide/SpoilerRobotoTextView.java b/app/src/main/java/me/edgan/redditslide/SpoilerRobotoTextView.java index 3c266ddf5..e19a1bddd 100644 --- a/app/src/main/java/me/edgan/redditslide/SpoilerRobotoTextView.java +++ b/app/src/main/java/me/edgan/redditslide/SpoilerRobotoTextView.java @@ -88,9 +88,9 @@ public class SpoilerRobotoTextView extends RobotoTextView implements ClickableTe private List storedSpoilerSpans = new ArrayList<>(); private List storedSpoilerStarts = new ArrayList<>(); private List storedSpoilerEnds = new ArrayList<>(); - private static final Pattern htmlSpoilerPattern = + public static final Pattern htmlSpoilerPattern = Pattern.compile("([^<]*)"); - private static final Pattern nativeSpoilerPattern = + public static final Pattern nativeSpoilerPattern = Pattern.compile("([^<]*)"); private static class MatchPair { diff --git a/app/src/main/java/me/edgan/redditslide/SubmissionViews/HeaderImageLinkView.java b/app/src/main/java/me/edgan/redditslide/SubmissionViews/HeaderImageLinkView.java index d1ec85bb7..d36123d3e 100644 --- a/app/src/main/java/me/edgan/redditslide/SubmissionViews/HeaderImageLinkView.java +++ b/app/src/main/java/me/edgan/redditslide/SubmissionViews/HeaderImageLinkView.java @@ -27,7 +27,6 @@ import com.nostra13.universalimageloader.core.assist.FailReason; import com.nostra13.universalimageloader.core.display.FadeInBitmapDisplayer; import com.nostra13.universalimageloader.core.listener.ImageLoadingListener; -import com.nostra13.universalimageloader.core.listener.SimpleImageLoadingListener; import me.edgan.redditslide.ContentType; import me.edgan.redditslide.ForceTouch.PeekView; @@ -83,6 +82,9 @@ public class HeaderImageLinkView extends RelativeLayout { public ImageView backdrop; private boolean forceThumb; + private static final List PLACEHOLDER_URLS = + Arrays.asList("self", "default", "image", "nsfw", "spoiler", ""); + public HeaderImageLinkView(Context context) { super(context); init(); @@ -867,44 +869,148 @@ private String getLowQualityVariationUrl(Submission submission) { } private void displayThumbnail(String url, boolean full) { + if (url == null || PLACEHOLDER_URLS.contains(url)) { + LogUtil.v("Displaying thumbnail - invalid or placeholder URL: " + url + ", hiding view and thumbImage2."); + setVisibility(View.GONE); // Hides HeaderImageLinkView + if (thumbImage2 != null) { + thumbImage2.setVisibility(View.GONE); + } + if (full && wrapArea != null) { // if full view, wrapArea might have been made visible + wrapArea.setVisibility(View.GONE); + } + return; + } + if (!full) { thumbImage2.setVisibility(View.VISIBLE); } else { wrapArea.setVisibility(View.VISIBLE); } loadedUrl = url; + + ImageLoadingListener detailedListener = new ImageLoadingListener() { + @Override + public void onLoadingStarted(String imageUri, View view) {} + + @Override + public void onLoadingFailed(String imageUri, View view, FailReason failReason) { + LogUtil.e("UIL (Thumbnail): Loading FAILED for: " + imageUri + ", reason: " + failReason.getType() + ", cause: " + (failReason.getCause() != null ? failReason.getCause().getMessage() : "null")); + if (HeaderImageLinkView.this != null) { + HeaderImageLinkView.this.setVisibility(View.GONE); + } + } + + @Override + public void onLoadingComplete(String imageUri, View view, android.graphics.Bitmap loadedBitmap) { + if (loadedBitmap != null) { + if (loadedBitmap.getWidth() == 0 || loadedBitmap.getHeight() == 0) { + LogUtil.w("UIL (Thumbnail): Loaded bitmap has zero width or height for " + imageUri); + if (HeaderImageLinkView.this != null) { + HeaderImageLinkView.this.setVisibility(View.GONE); // Hide if bitmap is unusable + } + } + } else { + LogUtil.w("UIL (Thumbnail): Loading COMPLETE for " + imageUri + " but bitmap is NULL."); + if (HeaderImageLinkView.this != null) { + HeaderImageLinkView.this.setVisibility(View.GONE); // Hide if bitmap is null + } + } + } + + @Override + public void onLoadingCancelled(String imageUri, View view) { + LogUtil.w("UIL (Thumbnail): Loading CANCELLED for " + imageUri); + if (HeaderImageLinkView.this != null) { + HeaderImageLinkView.this.setVisibility(View.GONE); + } + } + }; + ((Reddit) getContext().getApplicationContext()) .getImageLoader() - .displayImage(url, thumbImage2); - setVisibility(View.GONE); + .displayImage(url, thumbImage2, detailedListener); // Use detailedListener + setVisibility(View.GONE); // This line was already here for thumbnails } private void displayFullImage(String url, boolean full) { + if (url == null || PLACEHOLDER_URLS.contains(url)) { + LogUtil.v("Displaying full image - invalid or placeholder URL for backdrop: " + url + ", hiding view."); + setVisibility(View.GONE); + if (thumbImage2 != null) { + thumbImage2.setVisibility(View.GONE); + } + if (wrapArea != null) { + wrapArea.setVisibility(View.GONE); + } + return; + } + loadedUrl = url; + ImageLoadingListener detailedListener = new ImageLoadingListener() { + @Override + public void onLoadingStarted(String imageUri, View view) {} - // Create ImageLoadingListener to handle errors - ImageLoadingListener errorListener = new SimpleImageLoadingListener() { @Override public void onLoadingFailed(String imageUri, View view, FailReason failReason) { - HeaderImageLinkView.this.setVisibility(View.GONE); + LogUtil.e("UIL (FullImage): Loading FAILED for: " + imageUri + ", reason: " + failReason.getType() + ", cause: " + (failReason.getCause() != null ? failReason.getCause().getMessage() : "null")); + if (HeaderImageLinkView.this != null) { + HeaderImageLinkView.this.setVisibility(View.GONE); + } + } + + @Override + public void onLoadingComplete(String imageUri, View view, android.graphics.Bitmap loadedBitmap) { + if (loadedBitmap != null) { + if (loadedBitmap.getWidth() == 0 || loadedBitmap.getHeight() == 0) { + LogUtil.w("UIL (FullImage): Loaded bitmap has zero width or height for " + imageUri); + // Don't hide HeaderImageLinkView here by default, let adjustViewBounds try. + // If it results in 0 height, it will be invisible anyway. + // Only hide if explicitly desired for 0-dim images. + } + // Ensure backdrop is visible if we successfully loaded an image and HeaderImageLinkView is meant to be visible. + if (view instanceof ImageView && HeaderImageLinkView.this.getVisibility() == View.VISIBLE) { + ((ImageView) view).setVisibility(View.VISIBLE); + } + } else { + LogUtil.w("UIL (FullImage): Loading COMPLETE for " + imageUri + " but bitmap is NULL."); + if (HeaderImageLinkView.this != null) { + HeaderImageLinkView.this.setVisibility(View.GONE); // Hide if bitmap is null + } + } + } + + @Override + public void onLoadingCancelled(String imageUri, View view) { + LogUtil.w("UIL (FullImage): Loading CANCELLED for " + imageUri); + if (HeaderImageLinkView.this != null) { + HeaderImageLinkView.this.setVisibility(View.GONE); + } } }; + // Ensure backdrop ImageView itself is visible before loading, if HeaderImageLinkView is meant to be visible. + // This is because UIL won't make it visible, and its default state is visible from XML, + // but good to be explicit if we are about to load an image into it. + if (backdrop != null && getVisibility() == View.VISIBLE) { + backdrop.setVisibility(View.VISIBLE); + } + if (!full) { ((Reddit) getContext().getApplicationContext()) .getImageLoader() - .displayImage(url, backdrop, bigOptions, errorListener); + .displayImage(url, backdrop, null, detailedListener); } else { ((Reddit) getContext().getApplicationContext()) .getImageLoader() - .displayImage(url, backdrop, bigOptions, errorListener); + .displayImage(url, backdrop, bigOptions, detailedListener); } setVisibility(View.VISIBLE); + if (!full) { - thumbImage2.setVisibility(View.GONE); + if (thumbImage2 != null) thumbImage2.setVisibility(View.GONE); } else { - wrapArea.setVisibility(View.GONE); + if (wrapArea != null) wrapArea.setVisibility(View.GONE); } } diff --git a/app/src/main/java/me/edgan/redditslide/Tumblr/TumblrUtils.java b/app/src/main/java/me/edgan/redditslide/Tumblr/TumblrUtils.java index 7fa89e490..696d12b4d 100644 --- a/app/src/main/java/me/edgan/redditslide/Tumblr/TumblrUtils.java +++ b/app/src/main/java/me/edgan/redditslide/Tumblr/TumblrUtils.java @@ -152,13 +152,9 @@ protected ArrayList doInBackground(final String... sub) { + "&id=" + id; LogUtil.v(apiUrl); - if (tumblrRequests.contains(apiUrl) - && JsonParser.parseString(tumblrRequests.getString(apiUrl, "")) - .getAsJsonObject() - .has("response")) { - parseJson( - JsonParser.parseString(tumblrRequests.getString(apiUrl, "")) - .getAsJsonObject()); + if (tumblrRequests.contains(apiUrl) && JsonParser.parseString(tumblrRequests.getString(apiUrl, "")).getAsJsonObject().has("response")) { + Log.d(TAG, "parseJson: 1" + tumblrRequests.getString(apiUrl, "")); + parseJson(JsonParser.parseString(tumblrRequests.getString(apiUrl, "")).getAsJsonObject()); } else { LogUtil.v(apiUrl); final JsonObject result = HttpUtil.getJsonObject(client, gson, apiUrl); @@ -172,6 +168,7 @@ protected ArrayList doInBackground(final String... sub) { .get(0) .getAsJsonObject() .has("photos")) { + Log.d(TAG, "parseJson: 2" + result.toString()); tumblrRequests.edit().putString(apiUrl, result.toString()).apply(); parseJson(result); } else { diff --git a/app/src/main/java/me/edgan/redditslide/Views/ExoVideoView.java b/app/src/main/java/me/edgan/redditslide/Views/ExoVideoView.java index f4a2e8719..385d764ec 100644 --- a/app/src/main/java/me/edgan/redditslide/Views/ExoVideoView.java +++ b/app/src/main/java/me/edgan/redditslide/Views/ExoVideoView.java @@ -41,6 +41,7 @@ import androidx.media3.common.MimeTypes; import androidx.media3.common.VideoSize; + import me.edgan.redditslide.R; import me.edgan.redditslide.Reddit; import me.edgan.redditslide.SettingValues; @@ -62,11 +63,13 @@ public class ExoVideoView extends RelativeLayout { private PlayerControlView playerUI; private boolean muteAttached = false; private boolean hqAttached = false; + private boolean speedAttached = false; + private float[] speedOptions = {0.25f, 0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 2.0f}; + private int currentSpeedIndex = 3; // Normal (1.0x) default private AudioFocusHelper audioFocusHelper; private Handler handler = new Handler(Looper.getMainLooper()); private Runnable hideControlsRunnable; - private ScaleGestureDetector scaleGestureDetector; private float scaleFactor = 1.0f; private AspectRatioFrameLayout videoFrame; @@ -610,6 +613,121 @@ public void onTracksChanged(@NonNull Tracks tracks) { } } + /** + * Attaches a speed control button to this view. + */ + public void attachSpeedButton(final ImageView speed, final Context parentContext) { + Log.d(TAG, "attachSpeedButton() called"); + if (speed != null && player != null) { + speed.setVisibility(VISIBLE); + speed.setImageResource(R.drawable.ic_speed); + speed.setOnClickListener(v -> { + // Show a BottomSheetDialog to pick speed + String[] speedLabels = new String[] { + parentContext.getString(R.string.video_speed_0_25x), + parentContext.getString(R.string.video_speed_0_5x), + parentContext.getString(R.string.video_speed_0_75x), + parentContext.getString(R.string.video_speed_1x), + parentContext.getString(R.string.video_speed_1_25x), + parentContext.getString(R.string.video_speed_1_5x), + parentContext.getString(R.string.video_speed_2x) + }; + + android.widget.ListView listView = new android.widget.ListView(parentContext); + // Custom adapter to show speed label and icon for selected + android.widget.BaseAdapter adapter = new android.widget.BaseAdapter() { + @Override + public int getCount() { return speedLabels.length; } + @Override + public Object getItem(int position) { return speedLabels[position]; } + @Override + public long getItemId(int position) { return position; } + @Override + public android.view.View getView(int position, android.view.View convertView, android.view.ViewGroup parent) { + android.content.Context ctx = parent.getContext(); + android.widget.LinearLayout layout = new android.widget.LinearLayout(ctx); + layout.setOrientation(android.widget.LinearLayout.HORIZONTAL); + layout.setPadding(0, 0, 0, 0); + layout.setGravity(android.view.Gravity.CENTER_VERTICAL); + // Label + String labelText; + if (speedLabels[position].matches("[0-9.]+x")) { + // If the label is like "2x", format to 2.00x + try { + float val = Float.parseFloat(speedLabels[position].replace("x", "")); + labelText = String.format("%.2fx", val); + } catch (Exception e) { + labelText = speedLabels[position]; + } + } else { + labelText = speedLabels[position]; + } + android.widget.TextView label = new android.widget.TextView(ctx); + label.setText(labelText); + label.setTextColor(android.graphics.Color.WHITE); + // Use default text appearance for list items + label.setTextAppearance(android.R.style.TextAppearance_Material_Body1); + label.setPadding((int)(ctx.getResources().getDisplayMetrics().density*4), (int)(ctx.getResources().getDisplayMetrics().density*8), (int)(ctx.getResources().getDisplayMetrics().density*4), (int)(ctx.getResources().getDisplayMetrics().density*8)); + android.widget.LinearLayout.LayoutParams labelParams = new android.widget.LinearLayout.LayoutParams(0, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT, 1f); + label.setLayoutParams(labelParams); + layout.addView(label); + // Icon for selected + if (position == currentSpeedIndex) { + ImageView icon = new ImageView(ctx); + icon.setImageResource(R.drawable.ic_speed); + icon.setColorFilter(android.graphics.Color.WHITE, android.graphics.PorterDuff.Mode.SRC_IN); + int iconSize = (int)(ctx.getResources().getDisplayMetrics().density*24); + android.widget.LinearLayout.LayoutParams iconParams = new android.widget.LinearLayout.LayoutParams(iconSize, iconSize); + iconParams.setMarginStart((int)(ctx.getResources().getDisplayMetrics().density*8)); + icon.setLayoutParams(iconParams); + layout.addView(icon); + } + return layout; + } + }; + listView.setAdapter(adapter); + listView.setChoiceMode(android.widget.ListView.CHOICE_MODE_SINGLE); + listView.setItemChecked(currentSpeedIndex, true); + listView.setBackgroundColor(android.graphics.Color.BLACK); + listView.setDivider(null); // Remove the separator + listView.setDividerHeight(0); // Ensure no divider is shown + int horizontalPadding = (int) (parentContext.getResources().getDisplayMetrics().density * 24); // 24dp + int topPadding = (int) (parentContext.getResources().getDisplayMetrics().density * 12); // 12dp + listView.setPadding(horizontalPadding, topPadding, horizontalPadding, listView.getPaddingBottom()); + listView.setClipToPadding(false); + + com.google.android.material.bottomsheet.BottomSheetDialog bottomSheetDialog = new com.google.android.material.bottomsheet.BottomSheetDialog(parentContext); + bottomSheetDialog.setContentView(listView); + bottomSheetDialog.setTitle(parentContext.getString(R.string.video_speed)); + + // Set the background of the bottom sheet itself to black (no rounded corners) + bottomSheetDialog.setOnShowListener(dialog -> { + android.view.View bottomSheet = bottomSheetDialog.findViewById(com.google.android.material.R.id.design_bottom_sheet); + if (bottomSheet != null) { + bottomSheet.setBackgroundColor(android.graphics.Color.BLACK); + } + }); + + listView.setOnItemClickListener((parent, view, position, id) -> { + setPlaybackSpeed(speedOptions[position]); + currentSpeedIndex = position; + bottomSheetDialog.dismiss(); + }); + + bottomSheetDialog.show(); + }); + } + } + + /** + * Sets the playback speed of the player. + */ + public void setPlaybackSpeed(float speed) { + if (player != null) { + player.setPlaybackParameters(new androidx.media3.common.PlaybackParameters(speed)); + } + } + /** Enum for video types. */ public enum VideoType { STANDARD, diff --git a/app/src/main/java/me/edgan/redditslide/handler/TextViewLinkHandler.java b/app/src/main/java/me/edgan/redditslide/handler/TextViewLinkHandler.java index 963490ee4..6ad12e02e 100644 --- a/app/src/main/java/me/edgan/redditslide/handler/TextViewLinkHandler.java +++ b/app/src/main/java/me/edgan/redditslide/handler/TextViewLinkHandler.java @@ -6,6 +6,7 @@ import android.text.Spannable; import android.text.method.BaseMovementMethod; import android.text.style.URLSpan; +import android.text.style.ImageSpan; import android.view.MotionEvent; import android.widget.TextView; @@ -101,17 +102,64 @@ public boolean onTouchEvent(TextView widget, final Spannable buffer, MotionEvent handler.removeCallbacksAndMessages(null); if (!clickHandled) { - // regular click - if (link.length != 0) { - int i = 0; - if (sequence != null) { - i = sequence.getSpanEnd(link[0]); + URLSpan tappedUrlSpan = link[0]; + ImageSpan[] imageSpansAtTapOffset = buffer.getSpans(off, off, ImageSpan.class); + int urlSpanStart = buffer.getSpanStart(tappedUrlSpan); + int urlSpanEnd = buffer.getSpanEnd(tappedUrlSpan); + + ImageSpan[] allImageSpansInUrl = buffer.getSpans(urlSpanStart, urlSpanEnd, ImageSpan.class); + boolean hasImageInUrl = allImageSpansInUrl.length > 0; + boolean isEffectivelyImageOnlyLink = false; + + if (hasImageInUrl) { + isEffectivelyImageOnlyLink = true; + for (int i = urlSpanStart; i < urlSpanEnd; i++) { + boolean charIsImage = false; + for (ImageSpan imgSpan : allImageSpansInUrl) { + if (i >= buffer.getSpanStart(imgSpan) && i < buffer.getSpanEnd(imgSpan)) { + charIsImage = true; + break; + } + } + if (!charIsImage && !Character.isWhitespace(buffer.charAt(i))) { + isEffectivelyImageOnlyLink = false; + break; + } } - if (!link[0].getURL().isEmpty()) { - clickableText.onLinkClick(link[0].getURL(), i, subreddit, link[0]); + } + + if (isEffectivelyImageOnlyLink) { + if (imageSpansAtTapOffset.length > 0) { + ImageSpan tappedImageSpan = imageSpansAtTapOffset[0]; + android.graphics.drawable.Drawable drawable = tappedImageSpan.getDrawable(); + + if (drawable != null && drawable.getBounds().width() > 0 && drawable.getBounds().height() > 0) { + int spanStartOffset = buffer.getSpanStart(tappedImageSpan); + + float imageDrawStartX = layout.getPrimaryHorizontal(spanStartOffset); + float imageDrawEndX = imageDrawStartX + drawable.getBounds().width(); + + int imageStartLine = layout.getLineForOffset(spanStartOffset); + float imageDrawEndY = layout.getLineBottom(imageStartLine); + float imageDrawStartY = imageDrawEndY - drawable.getBounds().height(); + + if (x >= imageDrawStartX && x < imageDrawEndX && + y >= imageDrawStartY && y < imageDrawEndY) { + clickableText.onLinkClick(tappedUrlSpan.getURL(), urlSpanEnd, subreddit, tappedUrlSpan); + } else { + Selection.removeSelection(buffer); + return false; + } + } else { + Selection.removeSelection(buffer); + return false; + } + } else { + Selection.removeSelection(buffer); + return false; } } else { - return false; + clickableText.onLinkClick(tappedUrlSpan.getURL(), urlSpanEnd, subreddit, tappedUrlSpan); } } break; diff --git a/app/src/main/java/me/edgan/redditslide/util/GifDrawable.java b/app/src/main/java/me/edgan/redditslide/util/GifDrawable.java index 4f75b37ce..db52849f7 100644 --- a/app/src/main/java/me/edgan/redditslide/util/GifDrawable.java +++ b/app/src/main/java/me/edgan/redditslide/util/GifDrawable.java @@ -59,6 +59,16 @@ public void setColorFilter(ColorFilter colorFilter) { invalidateSelf(); } + @Override + public int getIntrinsicWidth() { + return movie != null ? movie.width() : 0; + } + + @Override + public int getIntrinsicHeight() { + return movie != null ? movie.height() : 0; + } + @Override public int getOpacity() { return PixelFormat.TRANSLUCENT; diff --git a/app/src/main/java/me/edgan/redditslide/util/GifUtils.java b/app/src/main/java/me/edgan/redditslide/util/GifUtils.java index 3e7b34417..ba41f0f23 100644 --- a/app/src/main/java/me/edgan/redditslide/util/GifUtils.java +++ b/app/src/main/java/me/edgan/redditslide/util/GifUtils.java @@ -580,6 +580,7 @@ protected void onPreExecute() { public enum VideoType { IMGUR, STREAMABLE, + TUMBLR, GFYCAT, DIRECT, OTHER, @@ -662,6 +663,8 @@ public static VideoType getVideoType(String url) { if (realURL.contains("streamable.com")) return VideoType.STREAMABLE; + if (realURL.contains("tumblr.com")) return VideoType.TUMBLR; + return VideoType.OTHER; } @@ -924,6 +927,8 @@ protected Uri doInBackground(String... sub) { case REDDIT_GALLERY: return Uri.parse(url); case DIRECT: + case TUMBLR: + return Uri.parse(url); case IMGUR: try { return Uri.parse(url); diff --git a/app/src/main/res/drawable/ic_speed.xml b/app/src/main/res/drawable/ic_speed.xml new file mode 100644 index 000000000..e99aa54c7 --- /dev/null +++ b/app/src/main/res/drawable/ic_speed.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/layout/activity_media.xml b/app/src/main/res/layout/activity_media.xml index 40b97b49a..722bae3ae 100644 --- a/app/src/main/res/layout/activity_media.xml +++ b/app/src/main/res/layout/activity_media.xml @@ -57,31 +57,6 @@ android:id="@+id/gifheader" android:gravity="right|bottom" android:weightSum="6"> - - - - - + + + + - - - + android:gravity="bottom" + sothree:umanoOverlay="true" + sothree:umanoPanelHeight="48dp" + sothree:umanoShadowHeight="0dp"> + + android:orientation="vertical"> - - - - + + + + + + + - - - + android:layout_height="56dp" + android:layout_alignParentBottom="true"> + + - - - - - - + + + + + + + + + + + android:id="@+id/playbutton" + android:layout_width="72dp" + android:layout_height="72dp" + android:layout_centerInParent="true" + android:alpha="0.8" + android:background="@drawable/circle_background" + android:padding="16dp" + android:src="@drawable/ic_play" + android:visibility="visible" /> - - - - + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bcc7c7542..9eee6ad48 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1321,4 +1321,15 @@ Hide subscribed subreddit tabs Hide subscribed subreddits as tabs, but keep navigation buttons + + + Speed + 0.25x + 0.5x + 0.75x + Normal + 1.25x + 1.5x + 2x + Playback speed control \ No newline at end of file diff --git a/app/src/test/java/me/edgan/redditslide/test/ContentTypeTest.java b/app/src/test/java/me/edgan/redditslide/test/ContentTypeTest.java index fd8f3cebe..a247697bc 100644 --- a/app/src/test/java/me/edgan/redditslide/test/ContentTypeTest.java +++ b/app/src/test/java/me/edgan/redditslide/test/ContentTypeTest.java @@ -8,7 +8,6 @@ import me.edgan.redditslide.ContentType; import me.edgan.redditslide.ContentType.Type; -import me.edgan.redditslide.Reddit; import me.edgan.redditslide.SettingValues; import org.junit.BeforeClass; @@ -150,23 +149,6 @@ public void detectsWithoutScheme() { assertThat(ContentType.getContentType("//google.com"), is(not(Type.NONE))); } - @Test - public void detectsVideo() { - Reddit.videoPlugin = true; - assertThat( - ContentType.getContentType("https://www.youtube.com/watch?v=lX_pF03vCSU"), - is(Type.VIDEO)); - assertThat(ContentType.getContentType("https://youtu.be/lX_pF03vCSU"), is(Type.VIDEO)); - - assertThat(ContentType.getContentType("https://www.gifyoutube.com/"), is(not(Type.VIDEO))); - - Reddit.videoPlugin = false; - assertThat( - ContentType.getContentType("https://www.youtube.com/watch?v=lX_pF03vCSU"), - is(not(Type.VIDEO))); - assertThat(ContentType.getContentType("https://youtu.be/lX_pF03vCSU"), is(not(Type.VIDEO))); - } - @Test public void detectsStreamable() { assertThat(ContentType.getContentType("https://streamable.com/l41f"), is(Type.STREAMABLE)); diff --git a/app/src/test/java/me/edgan/redditslide/test/OpenRedditLinkTest.java b/app/src/test/java/me/edgan/redditslide/test/OpenRedditLinkTest.java index 9169cf4f0..698fb68f2 100644 --- a/app/src/test/java/me/edgan/redditslide/test/OpenRedditLinkTest.java +++ b/app/src/test/java/me/edgan/redditslide/test/OpenRedditLinkTest.java @@ -10,7 +10,10 @@ import me.edgan.redditslide.OpenRedditLink.RedditLinkType; import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +@RunWith(RobolectricTestRunner.class) public class OpenRedditLinkTest { // Less characters diff --git a/app/src/test/java/me/edgan/redditslide/test/SpoilerTextTest.java b/app/src/test/java/me/edgan/redditslide/test/SpoilerTextTest.java index 6b4e51623..db2e33fe6 100644 --- a/app/src/test/java/me/edgan/redditslide/test/SpoilerTextTest.java +++ b/app/src/test/java/me/edgan/redditslide/test/SpoilerTextTest.java @@ -4,23 +4,25 @@ import me.edgan.redditslide.SpoilerRobotoTextView; +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.powermock.core.classloader.annotations.PrepareForTest; import org.powermock.modules.junit4.PowerMockRunner; -import org.powermock.reflect.Whitebox; import java.util.ArrayList; import java.util.List; import java.util.regex.Pattern; @RunWith(PowerMockRunner.class) -@PrepareForTest(SpoilerRobotoTextView.class) public class SpoilerTextTest { - private final Pattern htmlSpoilerPattern = - Whitebox.getInternalState(SpoilerRobotoTextView.class, "htmlSpoilerPattern"); - private final Pattern nativeSpoilerPattern = - Whitebox.getInternalState(SpoilerRobotoTextView.class, "nativeSpoilerPattern"); + private Pattern htmlSpoilerPattern; + private Pattern nativeSpoilerPattern; + + @Before + public void setUp() { + htmlSpoilerPattern = SpoilerRobotoTextView.htmlSpoilerPattern; + nativeSpoilerPattern = SpoilerRobotoTextView.nativeSpoilerPattern; + } private final List htmlSpoilerTests = new ArrayList() {