Improve layout responsiveness

Make layouts better on all screen sizes.
This commit is contained in:
OxygenCobalt 2021-02-13 10:20:48 -07:00
parent e0485ebad9
commit f1245d7d40
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
10 changed files with 247 additions and 34 deletions

View file

@ -31,7 +31,7 @@ If you do make a request, provide the following:
- Why do you think it will benefit everyone's usage of the app? - Why do you think it will benefit everyone's usage of the app?
If you have the knowledge, you can also implement the feature yourself and create a [Pull Request](https://github.com/OxygenCobalt/Auxio/pulls), but its recommended that **you create an issue beforehand to give me a heads up.** If you have the knowledge, you can also implement the feature yourself and create a [Pull Request](https://github.com/OxygenCobalt/Auxio/pulls), but its recommended that **you create an issue beforehand to give me a heads up.**
Its also recommended that you read about [Auxio's Architecture](../info/ARCHITECTURE.md) so that your change does not harm the codebase. Its also recommended that you read about [Auxio's Architecture](../info/ARCHITECTURE.md) as well to make changes better and more efficient.
## Translations ## Translations

View file

@ -17,11 +17,8 @@ class AuxioApp : Application(), ImageLoaderFactory {
} }
override fun newImageLoader(): ImageLoader { override fun newImageLoader(): ImageLoader {
// Don't cache images on-disk [The covers are already on-disk]
// Crossfade by default
// Use a transparent placeholder
return ImageLoader.Builder(applicationContext) return ImageLoader.Builder(applicationContext)
.diskCachePolicy(CachePolicy.DISABLED) .diskCachePolicy(CachePolicy.DISABLED) // Not downloading anything, so no disk-caching
.crossfade(true) .crossfade(true)
.placeholder(android.R.color.transparent) .placeholder(android.R.color.transparent)
.build() .build()

View file

@ -77,5 +77,5 @@ class AlbumArtFetcher(private val context: Context) : Fetcher<Album> {
return loadMediaStoreCovers(data) return loadMediaStoreCovers(data)
} }
override fun key(data: Album) = data.id.toString() override fun key(data: Album) = data.coverUri.toString()
} }

View file

@ -83,7 +83,7 @@ class MosaicFetcher(private val context: Context) : Fetcher<Parent> {
} }
/** /**
* Create the mosaic, Code adapted from Phonograph * Create the mosaic image, Code adapted from Phonograph
* https://github.com/kabouzeid/Phonograph * https://github.com/kabouzeid/Phonograph
*/ */
private fun drawMosaic(streams: List<InputStream>): Bitmap { private fun drawMosaic(streams: List<InputStream>): Bitmap {
@ -122,16 +122,13 @@ class MosaicFetcher(private val context: Context) : Fetcher<Parent> {
} }
/** /**
* Iterate through a list of [Closeable]s, running [use] on each. * Iterate through a list of [Closeable]s, running [block] on each and closing it when done.
* @param action What to do for each [Closeable]
*/ */
private fun <T : Closeable> List<T>.useForEach(action: (T) -> Unit) { private fun <T : Closeable> List<T>.useForEach(block: (T) -> Unit) {
forEach { forEach { it.use(block) }
it.use(action)
}
} }
override fun key(data: Parent): String = data.id.toString() override fun key(data: Parent): String = data.hashCode().toString()
override fun handles(data: Parent) = data !is Album // Albums are not used here override fun handles(data: Parent) = data !is Album // Albums are not used here
companion object { companion object {

View file

@ -33,7 +33,7 @@ fun Fragment.newMenu(anchor: View, data: BaseModel, flag: Int = ActionMenu.FLAG_
* @param activity [AppCompatActivity] required as both a context and ViewModelStore owner. * @param activity [AppCompatActivity] required as both a context and ViewModelStore owner.
* @param anchor [View] This should be centered around * @param anchor [View] This should be centered around
* @param data [BaseModel] this menu corresponds to * @param data [BaseModel] this menu corresponds to
* @param flag Any extra flags to accompany the data. See [FLAG_NONE], [FLAG_IN_ALBUM], [FLAG_IN_ARTIST], [FLAG_IN_GENRE] for more detials. * @param flag Any extra flags to accompany the data. See [FLAG_NONE], [FLAG_IN_ALBUM], [FLAG_IN_ARTIST], [FLAG_IN_GENRE] for more details.
* @throws IllegalArgumentException When there is no menu for this specific datatype/flag * @throws IllegalArgumentException When there is no menu for this specific datatype/flag
*/ */
class ActionMenu( class ActionMenu(

View file

@ -162,13 +162,22 @@ fun isTablet(resources: Resources): Boolean {
} }
/** /**
* Get the span count for most RecyclerViews * Determine if the tablet is XLARGE, ignoring normal tablets.
*/
fun isXLTablet(resources: Resources): Boolean {
val layout = resources.configuration.screenLayout and Configuration.SCREENLAYOUT_SIZE_MASK
return layout == Configuration.SCREENLAYOUT_SIZE_XLARGE
}
/**
* Get the span count for most RecyclerViews. These probably work right on most displays. Trust me.
*/ */
fun RecyclerView.getSpans(): Int { fun RecyclerView.getSpans(): Int {
return if (isLandscape(resources)) { return if (isLandscape(resources)) {
if (isTablet(resources)) 3 else 2 if (isXLTablet(resources)) 3 else 2
} else { } else {
if (isTablet(resources)) 2 else 1 if (isXLTablet(resources)) 2 else 1
} }
} }

View file

@ -4,9 +4,6 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
tools:context=".playback.PlaybackFragment"> tools:context=".playback.PlaybackFragment">
<!-- FIXME: Way controls are designed can cause problems on displays 4.5 inch or smaller.
Need to fix it but the way I did it with the other layouts will create bad spacing here -->
<data> <data>
<variable <variable
@ -154,7 +151,7 @@
tools:text="11:38" /> tools:text="11:38" />
<TextView <TextView
android:id="@+id/playback_song_container_duration" android:id="@+id/playback_song_duration"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/margin_mid_large" android:layout_marginEnd="@dimen/margin_mid_large"
@ -166,25 +163,25 @@
<ImageButton <ImageButton
android:id="@+id/playback_loop" android:id="@+id/playback_loop"
style="@style/Widget.Button.Unbounded" style="@style/Widget.Button.Unbounded"
android:layout_marginEnd="@dimen/margin_large" android:layout_marginStart="@dimen/margin_mid_small"
android:contentDescription="@string/description_change_loop" android:contentDescription="@string/description_change_loop"
android:onClick="@{() -> playbackModel.incrementLoopStatus()}" android:onClick="@{() -> playbackModel.incrementLoopStatus()}"
android:src="@drawable/ic_loop" android:src="@drawable/ic_loop"
app:layout_constraintBottom_toBottomOf="@+id/playback_skip_prev" app:layout_constraintBottom_toBottomOf="@+id/playback_skip_prev"
app:layout_constraintEnd_toStartOf="@+id/playback_skip_prev"
app:layout_constraintHorizontal_chainStyle="packed" app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="@+id/playback_duration_current"
app:layout_constraintTop_toTopOf="@+id/playback_skip_prev" /> app:layout_constraintTop_toTopOf="@+id/playback_skip_prev" />
<ImageButton <ImageButton
android:id="@+id/playback_skip_prev" android:id="@+id/playback_skip_prev"
style="@style/Widget.Button.Unbounded" style="@style/Widget.Button.Unbounded"
android:layout_marginEnd="@dimen/margin_large"
android:background="@drawable/ui_unbounded_ripple" android:background="@drawable/ui_unbounded_ripple"
android:contentDescription="@string/description_skip_prev" android:contentDescription="@string/description_skip_prev"
android:onClick="@{() -> playbackModel.skipPrev()}" android:onClick="@{() -> playbackModel.skipPrev()}"
android:src="@drawable/ic_skip_prev" android:src="@drawable/ic_skip_prev"
app:layout_constraintBottom_toBottomOf="@+id/playback_play_pause" app:layout_constraintBottom_toBottomOf="@+id/playback_play_pause"
app:layout_constraintEnd_toStartOf="@+id/playback_play_pause" app:layout_constraintEnd_toStartOf="@+id/playback_play_pause"
app:layout_constraintStart_toEndOf="@+id/playback_loop"
app:layout_constraintTop_toTopOf="@+id/playback_play_pause" /> app:layout_constraintTop_toTopOf="@+id/playback_play_pause" />
<ImageButton <ImageButton
@ -193,34 +190,33 @@
android:contentDescription="@string/description_play_pause" android:contentDescription="@string/description_play_pause"
android:onClick="@{() -> playbackModel.invertPlayingStatus()}" android:onClick="@{() -> playbackModel.invertPlayingStatus()}"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/playback_song_container_duration" app:layout_constraintEnd_toEndOf="@+id/playback_song_duration"
app:layout_constraintStart_toStartOf="@+id/playback_duration_current" app:layout_constraintStart_toStartOf="@+id/playback_duration_current"
app:layout_constraintTop_toBottomOf="@+id/playback_duration_current" app:layout_constraintTop_toBottomOf="@+id/playback_duration_current"
tools:src="@drawable/ic_play_to_pause" /> tools:src="@drawable/ic_play_to_pause" />
<ImageButton <ImageButton
android:id="@+id/playback_skip_next" android:id="@+id/playback_skip_next"
style="@style/Widget.Button.Unbounded" style="@style/Widget.Button.Unbounded"
android:layout_marginStart="@dimen/margin_large"
android:background="@drawable/ui_unbounded_ripple" android:background="@drawable/ui_unbounded_ripple"
android:contentDescription="@string/description_skip_next" android:contentDescription="@string/description_skip_next"
android:onClick="@{() -> playbackModel.skipNext()}" android:onClick="@{() -> playbackModel.skipNext()}"
android:src="@drawable/ic_skip_next" android:src="@drawable/ic_skip_next"
app:layout_constraintBottom_toBottomOf="@+id/playback_play_pause" app:layout_constraintBottom_toBottomOf="@+id/playback_play_pause"
app:layout_constraintEnd_toStartOf="@+id/playback_shuffle"
app:layout_constraintStart_toEndOf="@+id/playback_play_pause" app:layout_constraintStart_toEndOf="@+id/playback_play_pause"
app:layout_constraintTop_toTopOf="@+id/playback_play_pause" /> app:layout_constraintTop_toTopOf="@+id/playback_play_pause" />
<ImageButton <ImageButton
android:id="@+id/playback_shuffle" android:id="@+id/playback_shuffle"
style="@style/Widget.Button.Unbounded" style="@style/Widget.Button.Unbounded"
android:layout_marginStart="@dimen/margin_large"
android:background="@drawable/ui_unbounded_ripple" android:background="@drawable/ui_unbounded_ripple"
android:contentDescription="@{playbackModel.isShuffling() ? @string/description_shuffle_off : @string/description_shuffle_on" android:contentDescription="@{playbackModel.isShuffling() ? @string/description_shuffle_off : @string/description_shuffle_on"
android:onClick="@{() -> playbackModel.invertShuffleStatus()}" android:onClick="@{() -> playbackModel.invertShuffleStatus()}"
android:src="@drawable/ic_shuffle" android:src="@drawable/ic_shuffle"
android:layout_marginEnd="@dimen/margin_mid_small"
app:layout_constraintBottom_toBottomOf="@+id/playback_skip_next" app:layout_constraintBottom_toBottomOf="@+id/playback_skip_next"
app:layout_constraintStart_toEndOf="@+id/playback_skip_next" app:layout_constraintEnd_toEndOf="@+id/playback_song_duration"
app:layout_constraintTop_toTopOf="@+id/playback_skip_next" /> app:layout_constraintTop_toTopOf="@+id/playback_skip_next" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,214 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".playback.PlaybackFragment">
<data>
<variable
name="song"
type="org.oxycblt.auxio.music.Song" />
<variable
name="playbackModel"
type="org.oxycblt.auxio.playback.PlaybackViewModel" />
<variable
name="detailModel"
type="org.oxycblt.auxio.detail.DetailViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/playback_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
android:fitsSystemWindows="true">
<androidx.appcompat.widget.Toolbar
android:id="@+id/playback_toolbar"
style="@style/Toolbar.Style.Icon"
android:elevation="0dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:menu="@menu/menu_playback"
app:navigationIcon="@drawable/ic_down"
app:title="@string/label_playback" />
<ImageView
android:id="@+id/playback_cover"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_margin="@dimen/margin_mid_huge"
android:contentDescription="@{@string/description_album_cover(song.name)}"
android:elevation="@dimen/elevation_normal"
android:outlineProvider="bounds"
app:albumArt="@{song}"
app:layout_constraintBottom_toTopOf="@+id/playback_song"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/playback_toolbar"
tools:src="@drawable/ic_song" />
<TextView
android:id="@+id/playback_song"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_mid_huge"
android:layout_marginEnd="@dimen/margin_mid_huge"
android:ellipsize="marquee"
android:fontFamily="@font/inter_semibold"
android:marqueeRepeatLimit="marquee_forever"
android:onClick="@{() -> detailModel.navToItem(playbackModel.song)}"
android:singleLine="true"
android:text="@{song.name}"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
app:layout_constraintBottom_toTopOf="@+id/playback_artist"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Song Name" />
<TextView
android:id="@+id/playback_artist"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_mid_huge"
android:layout_marginEnd="@dimen/margin_mid_huge"
android:ellipsize="end"
android:onClick="@{() -> detailModel.navToItem(playbackModel.song.album.artist)}"
android:singleLine="true"
android:text="@{song.album.artist.name}"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textColor="?android:attr/textColorSecondary"
app:layout_constraintBottom_toTopOf="@+id/playback_album"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="Artist Name" />
<TextView
android:id="@+id/playback_album"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_mid_huge"
android:layout_marginEnd="@dimen/margin_mid_huge"
android:layout_marginBottom="@dimen/margin_medium"
android:ellipsize="end"
android:onClick="@{() -> detailModel.navToItem(playbackModel.song.album)}"
android:singleLine="true"
android:text="@{song.album.name}"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textColor="?android:attr/textColorSecondary"
app:layout_constraintBottom_toTopOf="@+id/playback_seek_bar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="Album Name" />
<SeekBar
android:id="@+id/playback_seek_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
android:paddingStart="@dimen/margin_mid_huge"
android:paddingEnd="@dimen/margin_mid_huge"
android:progressBackgroundTint="?android:attr/colorControlNormal"
android:progressTint="?attr/colorPrimary"
android:splitTrack="false"
android:thumbOffset="@dimen/offset_thumb"
android:thumbTint="?attr/colorPrimary"
app:layout_constraintBottom_toTopOf="@+id/playback_duration_current"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:progress="70" />
<TextView
android:id="@+id/playback_duration_current"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_mid_huge"
android:layout_marginBottom="@dimen/margin_medium"
app:layout_constraintBottom_toTopOf="@+id/playback_play_pause"
app:layout_constraintStart_toStartOf="parent"
tools:text="11:38" />
<TextView
android:id="@+id/playback_song_duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/margin_mid_huge"
android:layout_marginBottom="@dimen/margin_medium"
android:text="@{song.formattedDuration}"
app:layout_constraintBottom_toTopOf="@+id/playback_play_pause"
app:layout_constraintEnd_toEndOf="parent"
tools:text="16:16" />
<ImageButton
android:id="@+id/playback_loop"
style="@style/Widget.Button.Unbounded"
android:layout_marginEnd="@dimen/margin_large"
android:contentDescription="@string/description_change_loop"
android:onClick="@{() -> playbackModel.incrementLoopStatus()}"
android:src="@drawable/ic_loop"
app:layout_constraintBottom_toBottomOf="@+id/playback_skip_prev"
app:layout_constraintEnd_toStartOf="@+id/playback_skip_prev"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintTop_toTopOf="@+id/playback_skip_prev" />
<ImageButton
android:id="@+id/playback_skip_prev"
style="@style/Widget.Button.Unbounded"
android:layout_marginEnd="@dimen/margin_large"
android:background="@drawable/ui_unbounded_ripple"
android:contentDescription="@string/description_skip_prev"
android:onClick="@{() -> playbackModel.skipPrev()}"
android:src="@drawable/ic_skip_prev"
app:layout_constraintBottom_toBottomOf="@+id/playback_play_pause"
app:layout_constraintEnd_toStartOf="@+id/playback_play_pause"
app:layout_constraintTop_toTopOf="@+id/playback_play_pause" />
<ImageButton
android:id="@+id/playback_play_pause"
style="@style/PlayPause"
android:layout_marginBottom="@dimen/margin_large"
android:contentDescription="@string/description_play_pause"
android:onClick="@{() -> playbackModel.invertPlayingStatus()}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/playback_song_duration"
app:layout_constraintStart_toStartOf="@+id/playback_duration_current"
tools:src="@drawable/ic_play_to_pause" />
<ImageButton
android:id="@+id/playback_skip_next"
style="@style/Widget.Button.Unbounded"
android:layout_marginStart="@dimen/margin_large"
android:background="@drawable/ui_unbounded_ripple"
android:contentDescription="@string/description_skip_next"
android:onClick="@{() -> playbackModel.skipNext()}"
android:src="@drawable/ic_skip_next"
app:layout_constraintBottom_toBottomOf="@+id/playback_play_pause"
app:layout_constraintStart_toEndOf="@+id/playback_play_pause"
app:layout_constraintTop_toTopOf="@+id/playback_play_pause" />
<ImageButton
android:id="@+id/playback_shuffle"
style="@style/Widget.Button.Unbounded"
android:layout_marginStart="@dimen/margin_large"
android:background="@drawable/ui_unbounded_ripple"
android:contentDescription="@{playbackModel.isShuffling() ? @string/description_shuffle_off : @string/description_shuffle_on"
android:onClick="@{() -> playbackModel.invertShuffleStatus()}"
android:src="@drawable/ic_shuffle"
app:layout_constraintBottom_toBottomOf="@+id/playback_skip_next"
app:layout_constraintStart_toEndOf="@+id/playback_skip_next"
app:layout_constraintTop_toTopOf="@+id/playback_skip_next" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -3,7 +3,7 @@
<!-- Info namespace | App labels --> <!-- Info namespace | App labels -->
<string name="info_app_desc">Ein einfacher und flexibeler Musik-Player für Android.</string> <string name="info_app_desc">Ein einfacher und flexibeler Musik-Player für Android.</string>
<string name="info_channel_name">Musikwiedergabe</string> <string name="info_channel_name">Musikwiedergabe</string>
<string name="info_service_desc">der Musikwiedergabe-Service von Auxio</string> <string name="info_service_desc">der Musikwiedergabe-Dienst von Auxio</string>
<!-- Label Namespace | Static Labels --> <!-- Label Namespace | Static Labels -->
<string name="label_retry">Wieder Versuchen</string> <string name="label_retry">Wieder Versuchen</string>
@ -82,8 +82,8 @@
<string name="setting_behavior_at_end">Wenn eine Abspielliste zu Ende ist</string> <string name="setting_behavior_at_end">Wenn eine Abspielliste zu Ende ist</string>
<string name="setting_behavior_end_loop_pause">Weiderholen und Pausieren</string> <string name="setting_behavior_end_loop_pause">Weiderholen und Pausieren</string>
<string name="setting_behavior_end_loop">Weiderholen</string> <string name="setting_behavior_end_loop">Weiderholen</string>
<string name="setting_behavior_end_stop">Halten</string> <string name="setting_behavior_end_stop">Stoppen</string>
<string name="setting_behavior_keep_shuffle">Shuffle-Einstellung merken</string> <string name="setting_behavior_keep_shuffle">Zufällig-Einstellung merken</string>
<string name="setting_behavior_keep_shuffle_desc">Lassen Zufällig an, wenn ein neues Lied anspielen</string> <string name="setting_behavior_keep_shuffle_desc">Lassen Zufällig an, wenn ein neues Lied anspielen</string>
<string name="setting_behavior_rewind_prev">Zurückspulen, bevor zurück springen</string> <string name="setting_behavior_rewind_prev">Zurückspulen, bevor zurück springen</string>
<string name="setting_behavior_rewind_prev_desc">Zurückspulen, bevor zum vorheriger Lied springen</string> <string name="setting_behavior_rewind_prev_desc">Zurückspulen, bevor zum vorheriger Lied springen</string>

View file

@ -111,7 +111,7 @@ PlaybackStateManager───────────────────┘
`PlaybackStateManager` is the shared object that contains the master copy of the playback state, doing all operations on it. This object should ***NEVER*** be used in a UI, as it does not sanitize input and can cause major problems if a Volatile UI interacts with it. It's callback system is also prone to memory leaks if not cleared when done. `PlaybackViewModel` should be used instead, as it exposes stable data and safe functions that UI's can use to interact with the playback state. `PlaybackStateManager` is the shared object that contains the master copy of the playback state, doing all operations on it. This object should ***NEVER*** be used in a UI, as it does not sanitize input and can cause major problems if a Volatile UI interacts with it. It's callback system is also prone to memory leaks if not cleared when done. `PlaybackViewModel` should be used instead, as it exposes stable data and safe functions that UI's can use to interact with the playback state.
`PlaybackService`'s job is to use the playback state to manage the ExoPlayer instance and also modify the state depending on system events, such as when a button is pressed on a headset. It should **never** be bound to, mostly because there is no need given that `PlaybackViewModel` exposes the same data in a much safer fashion. `PlaybackService`'s job is to use the playback state to manage the ExoPlayer instance and notification and also modify the state depending on system events, such as when a button is pressed on a headset. It should **never** be bound to, mostly because there is no need given that `PlaybackViewModel` exposes the same data in a much safer fashion.
#### `.recycler` #### `.recycler`