diff --git a/app/src/main/java/androidx/media3/ui/PlayerControlView.kt b/app/src/main/java/androidx/media3/ui/PlayerControlView.kt new file mode 100644 index 0000000..cbc6c97 --- /dev/null +++ b/app/src/main/java/androidx/media3/ui/PlayerControlView.kt @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2023 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package androidx.media3.ui + +@androidx.media3.common.util.UnstableApi +fun PlayerControlView.updateAll() { + updateAll() +} + +@androidx.media3.common.util.UnstableApi +fun PlayerControlView.requestPlayPauseFocus() { + requestPlayPauseFocus() +} diff --git a/app/src/main/java/org/lineageos/glimpse/ext/Configuration.kt b/app/src/main/java/org/lineageos/glimpse/ext/Configuration.kt new file mode 100644 index 0000000..bc83a8d --- /dev/null +++ b/app/src/main/java/org/lineageos/glimpse/ext/Configuration.kt @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2023 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.lineageos.glimpse.ext + +import android.content.res.Configuration +import android.os.Build + +val Configuration.isNightMode + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + isNightModeActive + } else when (uiMode.and(Configuration.UI_MODE_NIGHT_MASK)) { + Configuration.UI_MODE_NIGHT_UNDEFINED -> null + Configuration.UI_MODE_NIGHT_NO -> false + Configuration.UI_MODE_NIGHT_YES -> true + else -> null + } diff --git a/app/src/main/java/org/lineageos/glimpse/ext/PlayerControlView.kt b/app/src/main/java/org/lineageos/glimpse/ext/PlayerControlView.kt new file mode 100644 index 0000000..46368fc --- /dev/null +++ b/app/src/main/java/org/lineageos/glimpse/ext/PlayerControlView.kt @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2023 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.lineageos.glimpse.ext + +import android.view.View +import androidx.media3.ui.PlayerControlView +import androidx.media3.ui.requestPlayPauseFocus +import androidx.media3.ui.updateAll + +@androidx.media3.common.util.UnstableApi +fun PlayerControlView.fade(visible: Boolean) { + (this as View).fade(visible) + + // This is needed to resume progress updating + if (visible) { + updateAll() + requestPlayPauseFocus() + show() + } +} diff --git a/app/src/main/java/org/lineageos/glimpse/ext/View.kt b/app/src/main/java/org/lineageos/glimpse/ext/View.kt new file mode 100644 index 0000000..4b49932 --- /dev/null +++ b/app/src/main/java/org/lineageos/glimpse/ext/View.kt @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2023 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.lineageos.glimpse.ext + +import android.view.View +import androidx.core.view.isVisible + +/** + * Get the system's default short animation time. + */ +val View.shortAnimTime + get() = resources.getInteger(android.R.integer.config_shortAnimTime) + +/** + * Update the [View]'s visibility using a fade animation. + * @param visible Whether the view should be visible or not. + */ +fun View.fade(visible: Boolean) { + with(animate()) { + cancel() + + if (visible && !isVisible) { + isVisible = true + } + + alpha( + when (visible) { + true -> 1f + false -> 0f + } + ) + + duration = shortAnimTime.toLong() + + setListener(null) + + withEndAction { + isVisible = visible + } + } +} diff --git a/app/src/main/java/org/lineageos/glimpse/ext/Window.kt b/app/src/main/java/org/lineageos/glimpse/ext/Window.kt new file mode 100644 index 0000000..f5f74fc --- /dev/null +++ b/app/src/main/java/org/lineageos/glimpse/ext/Window.kt @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: 2023 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.lineageos.glimpse.ext + +import android.view.Window +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +private val Window.windowInsetsController + get() = WindowInsetsControllerCompat(this, decorView) + +var Window.isAppearanceLightStatusBars + get() = windowInsetsController.isAppearanceLightStatusBars + set(value) { + windowInsetsController.isAppearanceLightStatusBars = value + } + +fun Window.resetStatusBarAppearance() { + windowInsetsController.isAppearanceLightStatusBars = + context.resources.configuration.isNightMode != true +} + +fun Window.setBarsVisibility( + systemBars: Boolean? = null, + statusBars: Boolean? = null, + navigationBars: Boolean? = null, +) { + // Configure the behavior of the hidden bars + windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT + + val systemBarsType = WindowInsetsCompat.Type.systemBars() + val statusBarsType = WindowInsetsCompat.Type.statusBars() + val navigationBarsType = WindowInsetsCompat.Type.navigationBars() + + // Set the system bars visibility + systemBars?.let { + when (it) { + true -> windowInsetsController.show(systemBarsType) + false -> windowInsetsController.hide(systemBarsType) + } + } + + // Set the status bars visibility + statusBars?.let { + when (it) { + true -> windowInsetsController.show(statusBarsType) + false -> windowInsetsController.hide(statusBarsType) + } + } + + // Set the navigation bars visibility + navigationBars?.let { + when (it) { + true -> windowInsetsController.show(navigationBarsType) + false -> windowInsetsController.hide(navigationBarsType) + } + } +} diff --git a/app/src/main/java/org/lineageos/glimpse/fragments/MediaViewerFragment.kt b/app/src/main/java/org/lineageos/glimpse/fragments/MediaViewerFragment.kt index d9ca651..fb74b1b 100644 --- a/app/src/main/java/org/lineageos/glimpse/fragments/MediaViewerFragment.kt +++ b/app/src/main/java/org/lineageos/glimpse/fragments/MediaViewerFragment.kt @@ -7,10 +7,11 @@ package org.lineageos.glimpse.fragments import android.app.Activity import android.content.Intent +import android.content.res.Configuration import android.os.Build import android.os.Bundle import android.view.View -import android.view.ViewGroup +import android.view.View.MeasureSpec import android.widget.ImageButton import android.widget.LinearLayout import android.widget.TextView @@ -19,14 +20,16 @@ import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.os.bundleOf import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle +import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.media3.common.C import androidx.media3.common.MediaItem +import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import androidx.navigation.fragment.findNavController import androidx.viewpager2.widget.ViewPager2 @@ -43,7 +46,7 @@ import org.lineageos.glimpse.models.MediaType import org.lineageos.glimpse.recyclerview.MediaViewerAdapter import org.lineageos.glimpse.ui.MediaInfoBottomSheetDialog import org.lineageos.glimpse.utils.PermissionsGatedCallback -import org.lineageos.glimpse.viewmodels.MediaViewModel +import org.lineageos.glimpse.viewmodels.MediaViewerViewModel import java.text.SimpleDateFormat /** @@ -51,9 +54,10 @@ import java.text.SimpleDateFormat * Use the [MediaViewerFragment.newInstance] factory method to * create an instance of this fragment. */ +@androidx.media3.common.util.UnstableApi class MediaViewerFragment : Fragment(R.layout.fragment_media_viewer) { // View models - private val mediaViewModel: MediaViewModel by viewModels { MediaViewModel.Factory } + private val mediaViewModel: MediaViewerViewModel by viewModels { MediaViewerViewModel.Factory } // Views private val adjustButton by getViewProperty(R.id.adjustButton) @@ -81,9 +85,19 @@ class MediaViewerFragment : Fragment(R.layout.fragment_media_viewer) { } // Player + private val exoPlayerListener = object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + super.onIsPlayingChanged(isPlaying) + + view?.keepScreenOn = isPlaying + } + } + private val exoPlayerLazy = lazy { ExoPlayer.Builder(requireContext()).build().apply { repeatMode = ExoPlayer.REPEAT_MODE_ONE + + addListener(exoPlayerListener) } } private val exoPlayer @@ -95,7 +109,7 @@ class MediaViewerFragment : Fragment(R.layout.fragment_media_viewer) { // Adapter private val mediaViewerAdapter by lazy { - MediaViewerAdapter(exoPlayerLazy, mediaViewModel.mediaPositionLiveData) + MediaViewerAdapter(exoPlayerLazy, mediaViewModel) } // Arguments @@ -208,6 +222,9 @@ class MediaViewerFragment : Fragment(R.layout.fragment_media_viewer) { super.onResume() exoPlayer?.play() + + // Force status bar icons to be light + requireActivity().window.isAppearanceLightStatusBars = false } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -270,15 +287,22 @@ class MediaViewerFragment : Fragment(R.layout.fragment_media_viewer) { ViewCompat.setOnApplyWindowInsetsListener(view) { _, windowInsets -> val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - topSheetConstraintLayout.updateLayoutParams { - leftMargin = insets.left - rightMargin = insets.right - topMargin = insets.top - } - bottomSheetLinearLayout.updateLayoutParams { - bottomMargin = insets.bottom - leftMargin = insets.left - rightMargin = insets.right + // Avoid updating the sheets height when they're hidden. + // Once the system bars will be made visible again, this function + // will be called again. + if (mediaViewModel.fullscreenModeLiveData.value != true) { + topSheetConstraintLayout.updatePadding( + left = insets.left, + right = insets.right, + top = insets.top, + ) + bottomSheetLinearLayout.updatePadding( + bottom = insets.bottom, + left = insets.left, + right = insets.right, + ) + + updateSheetsHeight() } windowInsets @@ -310,6 +334,22 @@ class MediaViewerFragment : Fragment(R.layout.fragment_media_viewer) { } } + view.findViewTreeLifecycleOwner()?.let { + mediaViewModel.fullscreenModeLiveData.observe(it) { fullscreenMode -> + topSheetConstraintLayout.fade(!fullscreenMode) + bottomSheetLinearLayout.fade(!fullscreenMode) + + requireActivity().window.setBarsVisibility(systemBars = !fullscreenMode) + + // If the sheets are being made visible again, update the values + if (!fullscreenMode) { + updateSheetsHeight() + } + } + } + + updateSheetsHeight() + permissionsGatedCallback.runAfterPermissionsCheck() } @@ -325,6 +365,12 @@ class MediaViewerFragment : Fragment(R.layout.fragment_media_viewer) { super.onPause() exoPlayer?.pause() + + // Restore status bar icons appearance + requireActivity().window.resetStatusBarAppearance() + + // Restore system bars visibility + requireActivity().window.setBarsVisibility(systemBars = true) } override fun onDestroy() { @@ -333,6 +379,12 @@ class MediaViewerFragment : Fragment(R.layout.fragment_media_viewer) { super.onDestroy() } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + + updateSheetsHeight() + } + private fun initData(data: List) { mediaViewerAdapter.data = data.toTypedArray() viewPager.setCurrentItem(mediaViewModel.mediaPosition, false) @@ -359,6 +411,16 @@ class MediaViewerFragment : Fragment(R.layout.fragment_media_viewer) { } } + private fun updateSheetsHeight() { + topSheetConstraintLayout.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) + bottomSheetLinearLayout.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) + + mediaViewModel.sheetsHeightLiveData.value = Pair( + topSheetConstraintLayout.measuredHeight, + bottomSheetLinearLayout.measuredHeight, + ) + } + companion object { private const val KEY_ALBUM = "album" private const val KEY_MEDIA = "media" diff --git a/app/src/main/java/org/lineageos/glimpse/recyclerview/MediaViewerAdapter.kt b/app/src/main/java/org/lineageos/glimpse/recyclerview/MediaViewerAdapter.kt index f18cf08..fe7ffca 100644 --- a/app/src/main/java/org/lineageos/glimpse/recyclerview/MediaViewerAdapter.kt +++ b/app/src/main/java/org/lineageos/glimpse/recyclerview/MediaViewerAdapter.kt @@ -10,19 +10,23 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageView import androidx.core.view.isVisible -import androidx.lifecycle.LiveData +import androidx.core.view.updateLayoutParams import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerControlView import androidx.media3.ui.PlayerView import androidx.recyclerview.widget.RecyclerView import coil.load import org.lineageos.glimpse.R +import org.lineageos.glimpse.ext.fade import org.lineageos.glimpse.models.Media import org.lineageos.glimpse.models.MediaType +import org.lineageos.glimpse.viewmodels.MediaViewerViewModel +@androidx.media3.common.util.UnstableApi class MediaViewerAdapter( private val exoPlayer: Lazy, - private val currentPositionLiveData: LiveData, + private val mediaViewerViewModel: MediaViewerViewModel, ) : RecyclerView.Adapter() { var data: Array = arrayOf() set(value) { @@ -47,7 +51,7 @@ class MediaViewerAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = MediaViewHolder( LayoutInflater.from(parent.context).inflate(R.layout.media_view, parent, false), - exoPlayer, currentPositionLiveData, + exoPlayer, mediaViewerViewModel ) override fun onBindViewHolder(holder: MediaViewHolder, position: Int) { @@ -69,25 +73,62 @@ class MediaViewerAdapter( class MediaViewHolder( private val view: View, private val exoPlayer: Lazy, - private val currentPositionLiveData: LiveData, + private val mediaViewerViewModel: MediaViewerViewModel, ) : RecyclerView.ViewHolder(view) { // Views private val imageView = view.findViewById(R.id.imageView) + private val playerControlView = view.findViewById(R.id.exo_controller) private val playerView = view.findViewById(R.id.playerView) private lateinit var media: Media private var position = -1 - private val observer = { currentPosition: Int -> + private val mediaPositionObserver = { currentPosition: Int -> val isNowVideoPlayer = currentPosition == position && media.mediaType == MediaType.VIDEO imageView.isVisible = !isNowVideoPlayer playerView.isVisible = isNowVideoPlayer - playerView.player = when (isNowVideoPlayer) { + if (!isNowVideoPlayer || mediaViewerViewModel.fullscreenModeLiveData.value == true) { + playerControlView.hideImmediately() + } else { + playerControlView.show() + } + + val player = when (isNowVideoPlayer) { true -> exoPlayer.value false -> null } + + playerView.player = player + playerControlView.player = player + } + + private val sheetsHeightObserver = { sheetsHeight: Pair -> + if (mediaViewerViewModel.fullscreenModeLiveData.value != true) { + val (topHeight, bottomHeight) = sheetsHeight + + // Place the player controls between the two sheets + playerControlView.updateLayoutParams { + topMargin = topHeight + bottomMargin = bottomHeight + } + } + } + + private val fullscreenModeObserver = { fullscreenMode: Boolean -> + if (media.mediaType == MediaType.VIDEO) { + playerControlView.fade(!fullscreenMode) + } + } + + init { + imageView.setOnClickListener { + mediaViewerViewModel.toggleFullscreenMode() + } + playerView.setOnClickListener { + mediaViewerViewModel.toggleFullscreenMode() + } } fun bind(media: Media, position: Int) { @@ -101,12 +142,16 @@ class MediaViewerAdapter( fun onViewAttachedToWindow() { view.findViewTreeLifecycleOwner()?.let { - currentPositionLiveData.observe(it, observer) + mediaViewerViewModel.mediaPositionLiveData.observe(it, mediaPositionObserver) + mediaViewerViewModel.sheetsHeightLiveData.observe(it, sheetsHeightObserver) + mediaViewerViewModel.fullscreenModeLiveData.observe(it, fullscreenModeObserver) } } fun onViewDetachedFromWindow() { - currentPositionLiveData.removeObserver(observer) + mediaViewerViewModel.mediaPositionLiveData.removeObserver(mediaPositionObserver) + mediaViewerViewModel.sheetsHeightLiveData.removeObserver(sheetsHeightObserver) + mediaViewerViewModel.fullscreenModeLiveData.removeObserver(fullscreenModeObserver) } } } diff --git a/app/src/main/java/org/lineageos/glimpse/viewmodels/MediaViewModel.kt b/app/src/main/java/org/lineageos/glimpse/viewmodels/MediaViewModel.kt index 95a5827..7a1ff10 100644 --- a/app/src/main/java/org/lineageos/glimpse/viewmodels/MediaViewModel.kt +++ b/app/src/main/java/org/lineageos/glimpse/viewmodels/MediaViewModel.kt @@ -19,7 +19,7 @@ import kotlinx.coroutines.flow.flatMapLatest import org.lineageos.glimpse.GlimpseApplication import org.lineageos.glimpse.repository.MediaRepository -class MediaViewModel( +open class MediaViewModel( private val savedStateHandle: SavedStateHandle, private val mediaRepository: MediaRepository ) : ViewModel() { private val mediaPositionInternal = savedStateHandle.getLiveData(MEDIA_POSITION_KEY) diff --git a/app/src/main/java/org/lineageos/glimpse/viewmodels/MediaViewerViewModel.kt b/app/src/main/java/org/lineageos/glimpse/viewmodels/MediaViewerViewModel.kt new file mode 100644 index 0000000..c237f05 --- /dev/null +++ b/app/src/main/java/org/lineageos/glimpse/viewmodels/MediaViewerViewModel.kt @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2023 The LineageOS Project + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.lineageos.glimpse.viewmodels + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import org.lineageos.glimpse.GlimpseApplication +import org.lineageos.glimpse.repository.MediaRepository + +class MediaViewerViewModel( + savedStateHandle: SavedStateHandle, + mediaRepository: MediaRepository, +) : MediaViewModel(savedStateHandle, mediaRepository) { + /** + * The current height of top and bottom sheets, used to apply padding to media view UI. + */ + val sheetsHeightLiveData = MutableLiveData>() + + /** + * Fullscreen mode, set by the user with a single tap on the viewed media. + */ + val fullscreenModeLiveData = MutableLiveData(false) + + /** + * Toggle fullscreen mode. + */ + fun toggleFullscreenMode() { + fullscreenModeLiveData.value = when (fullscreenModeLiveData.value) { + true -> false + else -> true + } + } + + companion object { + val Factory: ViewModelProvider.Factory = viewModelFactory { + initializer { + MediaViewerViewModel( + savedStateHandle = createSavedStateHandle(), + mediaRepository = (this[APPLICATION_KEY] as GlimpseApplication).mediaRepository, + ) + } + } + } +} diff --git a/app/src/main/res/drawable/bg_media_viewer_bottom_sheet.xml b/app/src/main/res/drawable/bg_media_viewer_bottom_sheet.xml new file mode 100644 index 0000000..19a4eaa --- /dev/null +++ b/app/src/main/res/drawable/bg_media_viewer_bottom_sheet.xml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_media_viewer_top_sheet.xml b/app/src/main/res/drawable/bg_media_viewer_top_sheet.xml new file mode 100644 index 0000000..9c1799c --- /dev/null +++ b/app/src/main/res/drawable/bg_media_viewer_top_sheet.xml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_media_viewer.xml b/app/src/main/res/layout/fragment_media_viewer.xml index 3d357b6..589a3c7 100644 --- a/app/src/main/res/layout/fragment_media_viewer.xml +++ b/app/src/main/res/layout/fragment_media_viewer.xml @@ -8,14 +8,24 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:background="@android:color/black" + android:theme="@style/Theme.Glimpse.MediaViewer" tools:context=".fragments.MediaViewerFragment"> + + @@ -26,6 +36,7 @@ android:layout_height="24dp" android:layout_marginTop="20dp" android:layout_marginBottom="20dp" + android:layout_marginStart="16dp" android:backgroundTint="@android:color/transparent" android:src="@drawable/ic_back" app:layout_constraintBottom_toBottomOf="parent" @@ -38,6 +49,7 @@ style="@style/Theme.Glimpse.MediaViewer.DateTimeText" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:layout_marginEnd="24dp" android:layout_marginTop="16dp" android:textAllCaps="true" android:textColor="?attr/colorOnSurface" @@ -50,6 +62,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="16dp" + android:layout_marginEnd="24dp" android:textAllCaps="true" android:textColor="?attr/colorOnSurface" app:layout_constraintBottom_toBottomOf="parent" @@ -57,19 +70,11 @@ app:layout_constraintTop_toBottomOf="@+id/dateTextView" /> - - + android:layout_height="match_parent"> + app:layout_constraintTop_toTopOf="parent" + app:show_timeout="0" + app:use_controller="false"> + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..0d6415a --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + + + @android:color/transparent + @android:color/transparent + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index e3c8b46..4451cf5 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -3,12 +3,15 @@ SPDX-FileCopyrightText: 2023 The LineageOS Project SPDX-License-Identifier: Apache-2.0 --> - + @@ -34,7 +37,7 @@ -