Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Support for Pixel motion photos

## [1.13.1] - 2026-02-14
### Changed
- Updated translations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ class SettingsActivity : SimpleActivity() {
setupLoopVideos()
setupOpenVideosOnSeparateScreen()
setupOnVideoTap()
setupAutoplayMotionPhotos()
setupLoopMotionPhotos()
setupMaxBrightness()
setupUltraHdrRendering()
setupCropThumbnails()
Expand Down Expand Up @@ -292,6 +294,22 @@ class SettingsActivity : SimpleActivity() {
}
}

private fun setupAutoplayMotionPhotos() {
binding.settingsAutoplayMotionPhotos.isChecked = config.autoplayMotionPhotos
binding.settingsAutoplayMotionPhotosHolder.setOnClickListener {
binding.settingsAutoplayMotionPhotos.toggle()
config.autoplayMotionPhotos = binding.settingsAutoplayMotionPhotos.isChecked
}
}

private fun setupLoopMotionPhotos() {
binding.settingsLoopMotionPhotos.isChecked = config.loopMotionPhotos
binding.settingsLoopMotionPhotosHolder.setOnClickListener {
binding.settingsLoopMotionPhotos.toggle()
config.loopMotionPhotos = binding.settingsLoopMotionPhotos.isChecked
}
}

private fun setupOpenVideosOnSeparateScreen() {
binding.settingsOpenVideosOnSeparateScreen.isChecked = config.gestureVideoPlayer
binding.settingsOpenVideosOnSeparateScreenHolder.setOnClickListener {
Expand Down Expand Up @@ -922,6 +940,8 @@ class SettingsActivity : SimpleActivity() {
put(GESTURE_VIDEO_PLAYER, config.gestureVideoPlayer)
put(VIDEO_PLAYER_TYPE, config.videoPlayerType)
put(ALLOW_VIDEO_GESTURES, config.allowVideoGestures)
put(AUTOPLAY_MOTION_PHOTOS, config.autoplayMotionPhotos)
put(LOOP_MOTION_PHOTOS, config.loopMotionPhotos)
put(ANIMATE_GIFS, config.animateGifs)
put(CROP_THUMBNAILS, config.cropThumbnails)
put(SHOW_THUMBNAIL_VIDEO_DURATION, config.showThumbnailVideoDuration)
Expand Down Expand Up @@ -1068,6 +1088,8 @@ class SettingsActivity : SimpleActivity() {
GESTURE_VIDEO_PLAYER -> config.gestureVideoPlayer = value.toBoolean()
VIDEO_PLAYER_TYPE -> config.videoPlayerType = value.toInt()
ALLOW_VIDEO_GESTURES -> config.allowVideoGestures = value.toBoolean()
AUTOPLAY_MOTION_PHOTOS -> config.autoplayMotionPhotos = value.toBoolean()
LOOP_MOTION_PHOTOS -> config.loopMotionPhotos = value.toBoolean()
ANIMATE_GIFS -> config.animateGifs = value.toBoolean()
CROP_THUMBNAILS -> config.cropThumbnails = value.toBoolean()
SHOW_THUMBNAIL_VIDEO_DURATION -> config.showThumbnailVideoDuration = value.toBoolean()
Expand Down
192 changes: 192 additions & 0 deletions app/src/main/kotlin/org/fossify/gallery/fragments/PhotoFragment.kt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:androidx.annotation.OptIn(markerClass = [androidx.media3.common.util.UnstableApi::class])

package org.fossify.gallery.fragments

import android.annotation.SuppressLint
Expand All @@ -6,6 +8,7 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color
import android.graphics.Matrix
import android.graphics.SurfaceTexture
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
Expand All @@ -17,6 +20,8 @@ import android.os.Handler
import android.util.DisplayMetrics
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.Surface
import android.view.TextureView
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
Expand All @@ -30,6 +35,13 @@ import androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_90
import androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSPOSE
import androidx.exifinterface.media.ExifInterface.ORIENTATION_TRANSVERSE
import androidx.exifinterface.media.ExifInterface.TAG_ORIENTATION
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.VideoSize
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.SeekParameters
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import com.alexvasilkov.gestures.GestureController
import com.alexvasilkov.gestures.State
import com.bumptech.glide.Glide
Expand Down Expand Up @@ -85,6 +97,9 @@ import org.fossify.gallery.extensions.getBottomActionsHeight
import org.fossify.gallery.extensions.sendFakeClick
import org.fossify.gallery.helpers.ColorModeHelper
import org.fossify.gallery.helpers.HIGH_TILE_DPI
import org.fossify.gallery.helpers.MotionPhotoDataSourceFactory
import org.fossify.gallery.helpers.MotionPhotoHelper
import org.fossify.gallery.helpers.MotionPhotoInfo
import org.fossify.gallery.helpers.LOW_TILE_DPI
import org.fossify.gallery.helpers.MAX_ZOOM_EQUALITY_TOLERANCE
import org.fossify.gallery.helpers.MEDIUM
Expand Down Expand Up @@ -129,6 +144,11 @@ class PhotoFragment : ViewPagerFragment() {
private var mCurrentGestureViewZoom = 1f
private var mInitialZoom = 1f
private var mHasInitialZoom = false
private var mIsMotionPhoto = false
private var mMotionPhotoInfo: MotionPhotoInfo? = null
private var mMotionPhotoPlayer: ExoPlayer? = null
private var mMotionPhotoSurface: Surface? = null
private var mIsMotionVideoPlaying = false

private var mStoredShowExtendedDetails = false
private var mStoredHideExtendedDetails = false
Expand Down Expand Up @@ -162,6 +182,12 @@ class PhotoFragment : ViewPagerFragment() {
instantPrevItem.setOnClickListener { listener?.goToPrevItem() }
instantNextItem.setOnClickListener { listener?.goToNextItem() }
panoramaOutline.setOnClickListener { openPanorama() }
motionPhotoPlay.setOnClickListener { playMotionPhotoVideo() }
motionPhotoSurface.setOnClickListener {
if (mIsMotionVideoPlaying) {
stopMotionPhotoVideo()
}
}

instantPrevItem.parentView = container
instantNextItem.parentView = container
Expand Down Expand Up @@ -257,11 +283,20 @@ class PhotoFragment : ViewPagerFragment() {
// checkIfPanorama()
// }

if (mMedium.isImage() && (mMedium.name.endsWith(".jpg", true) || mMedium.name.endsWith(".jpeg", true))) {
ensureBackgroundThread {
checkIfMotionPhoto()
}
}

return mView
}

override fun onPause() {
super.onPause()
if (mIsMotionVideoPlaying) {
stopMotionPhotoVideo()
}
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
storeStateVariables()
}
Expand Down Expand Up @@ -308,6 +343,7 @@ class PhotoFragment : ViewPagerFragment() {

override fun onDestroyView() {
super.onDestroyView()
stopMotionPhotoVideo()
if (activity?.isDestroyed == false) {
binding.subsamplingView.recycle()

Expand Down Expand Up @@ -353,6 +389,14 @@ class PhotoFragment : ViewPagerFragment() {
override fun setMenuVisibility(menuVisible: Boolean) {
super.setMenuVisibility(menuVisible)
mIsFragmentVisible = menuVisible
if (!menuVisible && mIsMotionVideoPlaying) {
stopMotionPhotoVideo()
}
val shouldAutoplayMotionPhoto = mIsMotionPhoto && !mIsMotionVideoPlaying &&
context?.config?.autoplayMotionPhotos == true
if (menuVisible && mWasInit && shouldAutoplayMotionPhoto) {
playMotionPhotoVideo()
}
if (mWasInit) {
val isNotAnimatedContent =
!mMedium.isGIF() && !mMedium.isApng() && !mMedium.isAvif() && !mMedium.isWebP()
Expand Down Expand Up @@ -852,6 +896,149 @@ class PhotoFragment : ViewPagerFragment() {
}
}

private fun checkIfMotionPhoto() {
val info = try {
MotionPhotoHelper.detectMotionPhoto(requireContext(), mMedium.path, mMedium.name)
} catch (e: Exception) {
null
}

mIsMotionPhoto = info != null
mMotionPhotoInfo = info

activity?.runOnUiThread {
if (mIsMotionPhoto && mIsFragmentVisible && requireContext().config.autoplayMotionPhotos) {
playMotionPhotoVideo()
} else {
binding.motionPhotoPlay.beVisibleIf(mIsMotionPhoto)
if (mIsFullscreen && mIsMotionPhoto) {
binding.motionPhotoPlay.alpha = 0f
}
}
}
}

private fun playMotionPhotoVideo() {
val info = mMotionPhotoInfo ?: return
if (mIsMotionVideoPlaying) return

mIsMotionVideoPlaying = true
binding.motionPhotoPlay.beGone()

val textureView = binding.motionPhotoSurface
textureView.beVisible()

if (textureView.isAvailable) {
val surface = Surface(textureView.surfaceTexture)
mMotionPhotoSurface = surface
initMotionPhotoPlayer(surface, info)
} else {
textureView.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
val surface = Surface(surfaceTexture)
mMotionPhotoSurface = surface
initMotionPhotoPlayer(surface, info)
}
override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, w: Int, h: Int) = Unit
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean = true
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) = Unit
}
}
}

private fun initMotionPhotoPlayer(surface: Surface, info: MotionPhotoInfo) {
if (activity == null) return

val factory = MotionPhotoDataSourceFactory(
requireContext(), mMedium.path, info.videoOffsetFromStart, info.videoLength
)

val mediaSource = ProgressiveMediaSource.Factory(factory)
.createMediaSource(MediaItem.fromUri(Uri.fromFile(File(mMedium.path))))

val shouldLoop = requireContext().config.loopMotionPhotos

mMotionPhotoPlayer = ExoPlayer.Builder(requireContext())
.setSeekParameters(SeekParameters.EXACT)
.build()
.apply {
repeatMode = if (shouldLoop) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
setVideoSurface(surface)
setMediaSource(mediaSource)
prepare()
playWhenReady = true

addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
activity?.runOnUiThread { stopMotionPhotoVideo() }
}
}

override fun onRenderedFirstFrame() {
activity?.runOnUiThread {
binding.gesturesView.beGone()
binding.subsamplingView.beGone()
}
}

override fun onVideoSizeChanged(videoSize: VideoSize) {
updateMotionPhotoSurfaceSize(videoSize)
}

override fun onPlayerError(error: PlaybackException) {
activity?.runOnUiThread { stopMotionPhotoVideo() }
}
})
}
}

private fun updateMotionPhotoSurfaceSize(videoSize: VideoSize) {
if (activity == null) return
val videoWidth = videoSize.width
val videoHeight = (videoSize.height / videoSize.pixelWidthHeightRatio).toInt()
if (videoWidth == 0 || videoHeight == 0) return

activity?.runOnUiThread {
val videoProportion = videoWidth.toFloat() / videoHeight.toFloat()
checkScreenDimensions()
val screenProportion = mScreenWidth.toFloat() / mScreenHeight.toFloat()

binding.motionPhotoSurface.layoutParams.apply {
if (videoProportion > screenProportion) {
width = mScreenWidth
height = (mScreenWidth.toFloat() / videoProportion).toInt()
} else {
width = (videoProportion * mScreenHeight.toFloat()).toInt()
height = mScreenHeight
}
binding.motionPhotoSurface.layoutParams = this
}
}
}

private fun stopMotionPhotoVideo() {
mMotionPhotoPlayer?.release()
mMotionPhotoPlayer = null
mMotionPhotoSurface?.release()
mMotionPhotoSurface = null
mIsMotionVideoPlaying = false

activity?.runOnUiThread {
binding.motionPhotoSurface.layoutParams.apply {
width = ViewGroup.LayoutParams.MATCH_PARENT
height = ViewGroup.LayoutParams.MATCH_PARENT
binding.motionPhotoSurface.layoutParams = this
}
binding.motionPhotoSurface.beGone()
binding.gesturesView.beVisible()
if (mIsSubsamplingVisible) {
binding.subsamplingView.beVisible()
}
binding.motionPhotoPlay.beVisibleIf(mIsMotionPhoto)
}
}

private fun getImageOrientation(): Int {
val defaultOrientation = -1
var orient = defaultOrientation
Expand Down Expand Up @@ -971,6 +1158,11 @@ class PhotoFragment : ViewPagerFragment() {
panoramaOutline.isClickable = !isFullscreen
}

if (mIsMotionPhoto && !mIsMotionVideoPlaying) {
motionPhotoPlay.animate().alpha(if (isFullscreen) 0f else 1f).start()
motionPhotoPlay.isClickable = !isFullscreen
}

if (mWasInit && mMedium.isPortrait()) {
photoPortraitStripeWrapper.animate().alpha(if (isFullscreen) 0f else 1f).start()
}
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/kotlin/org/fossify/gallery/helpers/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,14 @@ class Config(context: Context) : BaseConfig(context) {
get() = prefs.getBoolean(AUTOPLAY_VIDEOS, false)
set(autoplayVideos) = prefs.edit().putBoolean(AUTOPLAY_VIDEOS, autoplayVideos).apply()

var autoplayMotionPhotos: Boolean
get() = prefs.getBoolean(AUTOPLAY_MOTION_PHOTOS, false)
set(autoplayMotionPhotos) = prefs.edit().putBoolean(AUTOPLAY_MOTION_PHOTOS, autoplayMotionPhotos).apply()

var loopMotionPhotos: Boolean
get() = prefs.getBoolean(LOOP_MOTION_PHOTOS, false)
set(loopMotionPhotos) = prefs.edit().putBoolean(LOOP_MOTION_PHOTOS, loopMotionPhotos).apply()

var animateGifs: Boolean
get() = prefs.getBoolean(ANIMATE_GIFS, false)
set(animateGifs) = prefs.edit().putBoolean(ANIMATE_GIFS, animateGifs).apply()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const val LOOP_VIDEOS = "loop_videos"
const val MUTE_VIDEOS = "mute_videos"
const val GESTURE_VIDEO_PLAYER = "open_videos_on_separate_screen"
const val VIDEO_PLAYER_TYPE = "video_player_type"
const val AUTOPLAY_MOTION_PHOTOS = "autoplay_motion_photos"
const val LOOP_MOTION_PHOTOS = "loop_motion_photos"
const val ANIMATE_GIFS = "animate_gifs"
const val MAX_BRIGHTNESS = "max_brightness"
const val ULTRA_HDR_RENDERING = "ultra_hdr_rendering"
Expand Down
Loading
Loading