From c1e1329c211a104fb610d64fb0348a030a569140 Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Sun, 31 Oct 2021 11:14:04 -0600 Subject: [PATCH] playback: make compact playback ui a view Change CompactPlaybackFragment into a View. This completely fixes the issue I tried to band-aid in ae39054. The code is a bit uglier, but that's tolerable. --- .../java/org/oxycblt/auxio/MainFragment.kt | 48 +++++--- .../home/fastscroll/FastScrollRecyclerView.kt | 7 +- .../auxio/playback/CompactPlaybackFragment.kt | 90 --------------- .../auxio/playback/CompactPlaybackView.kt | 109 ++++++++++++++++++ .../auxio/playback/PlaybackBarLayout.kt | 96 +++++++-------- .../auxio/playback/PlaybackViewModel.kt | 1 - .../java/org/oxycblt/auxio/util/ViewUtil.kt | 4 + app/src/main/res/drawable/ui_shape_ripple.xml | 14 +++ app/src/main/res/layout/fragment_detail.xml | 1 - ...playback.xml => view_compact_playback.xml} | 15 +-- app/src/main/res/layout/view_playback_bar.xml | 11 -- app/src/main/res/values/attrs.xml | 5 - app/src/main/res/values/ids.xml | 3 + 13 files changed, 226 insertions(+), 178 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackFragment.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackView.kt create mode 100644 app/src/main/res/drawable/ui_shape_ripple.xml rename app/src/main/res/layout/{fragment_compact_playback.xml => view_compact_playback.xml} (88%) delete mode 100644 app/src/main/res/layout/view_playback_bar.xml diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 442dde45f..8021652a0 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -28,10 +28,13 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.res.ResourcesCompat import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController import com.google.android.material.snackbar.Snackbar import org.oxycblt.auxio.databinding.FragmentMainBinding +import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicViewModel +import org.oxycblt.auxio.playback.PlaybackBarLayout import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.util.logD @@ -39,8 +42,9 @@ import org.oxycblt.auxio.util.logD * A wrapper around the home fragment that shows the playback fragment and controls * the more high-level navigation features. */ -class MainFragment : Fragment() { +class MainFragment : Fragment(), PlaybackBarLayout.ActionCallback { private val playbackModel: PlaybackViewModel by activityViewModels() + private val detailModel: DetailViewModel by activityViewModels() private val musicModel: MusicViewModel by activityViewModels() override fun onCreateView( @@ -63,22 +67,26 @@ class MainFragment : Fragment() { // --- VIEWMODEL SETUP --- - if (playbackModel.song.value != null) { - binding.mainBarLayout.showBar() - } else { - binding.mainBarLayout.hideBar() - } + binding.mainBarLayout.setActionCallback(this) + + binding.mainBarLayout.setSong(playbackModel.song.value) + binding.mainBarLayout.setPlaying(playbackModel.isPlaying.value!!) + binding.mainBarLayout.setPosition(playbackModel.position.value!!) playbackModel.song.observe(viewLifecycleOwner) { song -> - if (song != null) { - binding.mainBarLayout.showBar() - } else { - binding.mainBarLayout.hideBar() - } + binding.mainBarLayout.setSong(song) } - // Initialize music loading. Unlike MainFragment, we can not only do this here on startup - // but also show a SnackBar in a reasonable place in this fragment. + playbackModel.isPlaying.observe(viewLifecycleOwner) { isPlaying -> + binding.mainBarLayout.setPlaying(isPlaying) + } + + playbackModel.position.observe(viewLifecycleOwner) { pos -> + binding.mainBarLayout.setPosition(pos) + } + + // Initialize music loading. Do it here so that it shows on every fragment that this + // one contains. musicModel.loadMusic(requireContext()) // Handle the music loader response. @@ -132,4 +140,18 @@ class MainFragment : Fragment() { return binding.root } + + override fun onPlayPauseClick() { + playbackModel.invertPlayingStatus() + } + + override fun onNavToPlayback() { + findNavController().navigate( + MainFragmentDirections.actionGoToPlayback() + ) + } + + override fun onNavToItem() { + detailModel.navToItem(playbackModel.song.value ?: return) + } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt index b1898588c..ccd1a78c7 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt @@ -42,6 +42,7 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.util.canScroll import org.oxycblt.auxio.util.resolveAttr import org.oxycblt.auxio.util.resolveDrawable +import org.oxycblt.auxio.util.systemBarsCompat import kotlin.math.abs /** @@ -324,12 +325,14 @@ class FastScrollRecyclerView @JvmOverloads constructor( } override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { + val bars = insets.systemBarsCompat + setPadding( initialPadding.left, initialPadding.top, initialPadding.right, - initialPadding.bottom + insets.systemWindowInsetBottom + initialPadding.bottom + bars.bottom ) - scrollerPadding.bottom = insets.systemWindowInsetBottom + scrollerPadding.bottom = bars.bottom return insets } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackFragment.kt deleted file mode 100644 index 51f828605..000000000 --- a/app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackFragment.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (c) 2021 Auxio Project - * CompactPlaybackFragment.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.playback - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.navigation.fragment.findNavController -import org.oxycblt.auxio.MainFragmentDirections -import org.oxycblt.auxio.databinding.FragmentCompactPlaybackBinding -import org.oxycblt.auxio.detail.DetailViewModel -import org.oxycblt.auxio.util.logD - -/** - * A [Fragment] that displays the currently played song at a glance, with some basic controls. - * Extends into [PlaybackFragment] when clicked on. - * - * Instantiation is done by FragmentContainerView, **do not instantiate this fragment manually.** - * @author OxygenCobalt - * TODO: Add more controls to this view depending on screen width - */ -class CompactPlaybackFragment : Fragment() { - private val playbackModel: PlaybackViewModel by activityViewModels() - private val detailModel: DetailViewModel by activityViewModels() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val binding = FragmentCompactPlaybackBinding.inflate(inflater) - - // --- UI SETUP --- - - binding.lifecycleOwner = viewLifecycleOwner - binding.playbackModel = playbackModel - binding.executePendingBindings() - - binding.root.apply { - setOnClickListener { - findNavController().navigate( - MainFragmentDirections.actionGoToPlayback() - ) - } - - setOnLongClickListener { - detailModel.navToItem(playbackModel.song.value!!) - true - } - } - - // --- VIEWMODEL SETUP --- - - playbackModel.song.observe(viewLifecycleOwner) { song -> - if (song != null) { - logD("Updating song display to ${song.name}") - - binding.song = song - binding.playbackProgress.max = song.seconds.toInt() - } - } - - playbackModel.isPlaying.observe(viewLifecycleOwner) { isPlaying -> - binding.playbackPlayPause.isActivated = isPlaying - } - - logD("Fragment Created") - - return binding.root - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackView.kt b/app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackView.kt new file mode 100644 index 000000000..8509ca4de --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackView.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2021 Auxio Project + * CompactPlaybackView.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.playback + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.drawable.RippleDrawable +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout +import com.google.android.material.shape.MaterialShapeDrawable +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.ViewCompactPlaybackBinding +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.util.inflater +import org.oxycblt.auxio.util.resolveAttr +import org.oxycblt.auxio.util.resolveDrawable + +/** + * A view displaying the playback state in a compact manner. This is only meant to be used + * by [PlaybackBarLayout]. + */ +class CompactPlaybackView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = -1 +) : ConstraintLayout(context, attrs, defStyleAttr) { + private val binding = ViewCompactPlaybackBinding.inflate(context.inflater, this, true) + private var mCallback: PlaybackBarLayout.ActionCallback? = null + + init { + id = R.id.playback_bar + + elevation = resources.getDimensionPixelSize(R.dimen.elevation_normal).toFloat() + + // To get a MaterialShapeDrawable to co-exist with a ripple drawable, we need to layer + // this drawable on top of the existing ripple drawable. RippleDrawable actually inherits + // LayerDrawable though, so we can do this. However, adding a new drawable layer directly + // is only available on API 23+, but we're on API 21. So we create a drawable resource with + // an empty drawable with a hard-coded ID, filling the drawable in with a + // MaterialShapeDrawable at runtime and allowing this code to work on API 21. + background = R.drawable.ui_shape_ripple.resolveDrawable(context).apply { + val backgroundDrawable = MaterialShapeDrawable.createWithElevationOverlay(context).apply { + elevation = this@CompactPlaybackView.elevation + fillColor = ColorStateList.valueOf(R.attr.colorSurface.resolveAttr(context)) + } + + (this as RippleDrawable).setDrawableByLayerId( + android.R.id.background, backgroundDrawable + ) + } + + isClickable = true + isFocusable = true + + setOnClickListener { + mCallback?.onNavToPlayback() + } + + setOnLongClickListener { + mCallback?.onNavToItem() + true + } + + binding.playbackPlayPause.setOnClickListener { + mCallback?.onPlayPauseClick() + } + } + + fun setSong(song: Song) { + binding.song = song + } + + fun setPlaying(isPlaying: Boolean) { + binding.playbackPlayPause.isActivated = isPlaying + } + + fun setPosition(position: Long) { + if (binding.song == null) { + binding.playbackProgress.progress = 0 + return + } + + binding.playbackProgress.progress = position.toInt() + } + + fun setCallback(callback: PlaybackBarLayout.ActionCallback) { + mCallback = callback + } + + fun clearCallback() { + mCallback = null + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarLayout.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarLayout.kt index 1db401be3..e2b9d5c9a 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarLayout.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarLayout.kt @@ -19,25 +19,18 @@ package org.oxycblt.auxio.playback import android.content.Context -import android.content.res.ColorStateList import android.graphics.Insets import android.os.Build -import android.os.Parcelable import android.util.AttributeSet import android.view.View import android.view.ViewGroup import android.view.WindowInsets -import android.widget.FrameLayout import androidx.annotation.AttrRes import androidx.annotation.StyleRes -import androidx.appcompat.app.AppCompatActivity import androidx.core.view.children import androidx.core.view.isVisible import androidx.core.view.updatePadding -import com.google.android.material.shape.MaterialShapeDrawable -import org.oxycblt.auxio.R -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.resolveAttr +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.util.systemBarsCompat /** @@ -46,9 +39,6 @@ import org.oxycblt.auxio.util.systemBarsCompat * this class was primarily written by me and I plan to expand this layout to become part of * the playback navigation process. * - * TODO: Migrate CompactPlaybackFragment to a view. This is okay, as updates can be delivered - * via MainFragment and it would fix the issue where the actual layout won't measure until - * the fragment is shown. * TODO: Implement animation * TODO: Implement the swipe-up behavior. This needs to occur, as the way the main fragment * saves state results in' @@ -59,37 +49,18 @@ class PlaybackBarLayout @JvmOverloads constructor( @AttrRes defStyleAttr: Int = 0, @StyleRes defStyleRes: Int = 0 ) : ViewGroup(context, attrs, defStyleAttr, defStyleRes) { - private val barLayout = FrameLayout(context) - private val playbackFragment = CompactPlaybackFragment() + private val playbackView = CompactPlaybackView(context) private var lastInsets: WindowInsets? = null init { - addView(barLayout) - - barLayout.apply { - id = R.id.main_playback - - elevation = resources.getDimensionPixelSize(R.dimen.elevation_normal).toFloat() + addView(playbackView) + playbackView.apply { (layoutParams as LayoutParams).apply { width = ViewGroup.LayoutParams.MATCH_PARENT height = ViewGroup.LayoutParams.WRAP_CONTENT isBar = true } - - background = MaterialShapeDrawable.createWithElevationOverlay(context).apply { - elevation = barLayout.elevation - fillColor = ColorStateList.valueOf(R.attr.colorSurface.resolveAttr(context)) - } - } - - if (!isInEditMode) { - (context as AppCompatActivity).supportFragmentManager.apply { - this - .beginTransaction() - .replace(R.id.main_playback, playbackFragment) - .commit() - } } } @@ -101,15 +72,15 @@ class PlaybackBarLayout @JvmOverloads constructor( setMeasuredDimension(widthSize, heightSize) - val barParams = barLayout.layoutParams as LayoutParams + val barParams = playbackView.layoutParams as LayoutParams val barWidthSpec = getChildMeasureSpec(widthMeasureSpec, 0, barParams.width) val barHeightSpec = getChildMeasureSpec(heightMeasureSpec, 0, barParams.height) - barLayout.measure(barWidthSpec, barHeightSpec) + playbackView.measure(barWidthSpec, barHeightSpec) updateWindowInsets() - val barHeightAdjusted = (barLayout.measuredHeight * barParams.offset).toInt() + val barHeightAdjusted = (playbackView.measuredHeight * barParams.offset).toInt() val contentWidth = measuredWidth val contentHeight = measuredHeight - barHeightAdjusted @@ -129,13 +100,13 @@ class PlaybackBarLayout @JvmOverloads constructor( } override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { - val barHeight = if (barLayout.isVisible) { - barLayout.measuredHeight + val barHeight = if (playbackView.isVisible) { + playbackView.measuredHeight } else { 0 } - val barHeightAdjusted = (barHeight * (barLayout.layoutParams as LayoutParams).offset).toInt() + val barHeightAdjusted = (barHeight * (playbackView.layoutParams as LayoutParams).offset).toInt() for (child in children) { if (child.visibility == View.GONE) continue @@ -154,7 +125,7 @@ class PlaybackBarLayout @JvmOverloads constructor( } override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets { - barLayout.updatePadding(bottom = insets.systemBarsCompat.bottom) + playbackView.updatePadding(bottom = insets.systemBarsCompat.bottom) lastInsets = insets updateWindowInsets() @@ -162,6 +133,12 @@ class PlaybackBarLayout @JvmOverloads constructor( return insets } + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + + playbackView.clearCallback() + } + private fun updateWindowInsets() { val insets = lastInsets @@ -171,8 +148,8 @@ class PlaybackBarLayout @JvmOverloads constructor( } private fun mutateInsets(insets: WindowInsets): WindowInsets { - val barParams = barLayout.layoutParams as LayoutParams - val childConsumedInset = (barLayout.measuredHeight * barParams.offset).toInt() + val barParams = playbackView.layoutParams as LayoutParams + val childConsumedInset = (playbackView.measuredHeight * barParams.offset).toInt() val bars = insets.systemBarsCompat @@ -194,8 +171,29 @@ class PlaybackBarLayout @JvmOverloads constructor( return insets } - fun showBar() { - val barParams = barLayout.layoutParams as LayoutParams + fun setSong(song: Song?) { + if (song != null) { + showBar() + playbackView.setSong(song) + } else { + hideBar() + } + } + + fun setPlaying(isPlaying: Boolean) { + playbackView.setPlaying(isPlaying) + } + + fun setPosition(position: Long) { + playbackView.setPosition(position) + } + + fun setActionCallback(callback: ActionCallback) { + playbackView.setCallback(callback) + } + + private fun showBar() { + val barParams = playbackView.layoutParams as LayoutParams if (barParams.offset == 1f) { return @@ -210,8 +208,8 @@ class PlaybackBarLayout @JvmOverloads constructor( invalidate() } - fun hideBar() { - val barParams = barLayout.layoutParams as LayoutParams + private fun hideBar() { + val barParams = playbackView.layoutParams as LayoutParams if (barParams.offset == 0f) { return @@ -260,4 +258,10 @@ class PlaybackBarLayout @JvmOverloads constructor( constructor(source: ViewGroup.LayoutParams) : super(source) } + + interface ActionCallback { + fun onPlayPauseClick() + fun onNavToItem() + fun onNavToPlayback() + } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt index 2e3112c83..fa4492a2c 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -419,7 +419,6 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback { * Restore playback on startup. This can do one of two things: * - Play a file intent that was given by MainActivity in [playWithUri] * - Restore the last playback state if there is no active file intent. - * TODO: Re-add this to HomeFragment once state can be restored */ fun setupPlayback(context: Context) { val intentUri = mIntentUri diff --git a/app/src/main/java/org/oxycblt/auxio/util/ViewUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/ViewUtil.kt index 7729585e9..76bfd2a13 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/ViewUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/ViewUtil.kt @@ -138,6 +138,10 @@ fun @receiver:AttrRes Int.resolveAttr(context: Context): Int { return color.resolveColor(context) } +/** + * Resolve window insets in a version-aware manner. This can be used to apply padding to + * a view that properly + */ val WindowInsets.systemBarsCompat: Rect get() { return when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { diff --git a/app/src/main/res/drawable/ui_shape_ripple.xml b/app/src/main/res/drawable/ui_shape_ripple.xml new file mode 100644 index 000000000..8cca1f401 --- /dev/null +++ b/app/src/main/res/drawable/ui_shape_ripple.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_detail.xml b/app/src/main/res/layout/fragment_detail.xml index 689d2e915..9663b4bbe 100644 --- a/app/src/main/res/layout/fragment_detail.xml +++ b/app/src/main/res/layout/fragment_detail.xml @@ -32,7 +32,6 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:clipToPadding="false" - app:layout_role="content" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" tools:listitem="@layout/item_detail" /> diff --git a/app/src/main/res/layout/fragment_compact_playback.xml b/app/src/main/res/layout/view_compact_playback.xml similarity index 88% rename from app/src/main/res/layout/fragment_compact_playback.xml rename to app/src/main/res/layout/view_compact_playback.xml index 3b88f2b9d..fc83fc9a8 100644 --- a/app/src/main/res/layout/fragment_compact_playback.xml +++ b/app/src/main/res/layout/view_compact_playback.xml @@ -2,7 +2,7 @@ + tools:context=".playback.CompactPlaybackView"> @@ -10,19 +10,17 @@ name="song" type="org.oxycblt.auxio.music.Song" /> - - + android:focusable="true" + tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"> @@ -82,12 +79,12 @@ android:id="@+id/playback_progress" android:layout_width="match_parent" android:layout_height="@dimen/size_stroke_large" + android:max="@{(int) song.seconds}" style="@style/Widget.Auxio.ProgressBar" - android:progress="@{playbackModel.positionAsProgress}" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" tools:progress="70" /> - + \ No newline at end of file diff --git a/app/src/main/res/layout/view_playback_bar.xml b/app/src/main/res/layout/view_playback_bar.xml deleted file mode 100644 index f062a19b5..000000000 --- a/app/src/main/res/layout/view_playback_bar.xml +++ /dev/null @@ -1,11 +0,0 @@ - - diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 7e92b50ea..21bbd907d 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -4,9 +4,4 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 8ee55ee73..6351210a8 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -1,5 +1,8 @@ + + +