music: move utils around
Move some miscellanious utils around.
This commit is contained in:
parent
4c954e83b0
commit
2e71342e1c
17 changed files with 97 additions and 104 deletions
|
@ -29,7 +29,7 @@ import org.oxycblt.auxio.databinding.DialogSongDetailBinding
|
|||
import org.oxycblt.auxio.ui.fragment.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.androidActivityViewModels
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.formatDurationMs
|
||||
import org.oxycblt.auxio.music.formatDurationMs
|
||||
|
||||
/**
|
||||
* A dialog displayed when "View properties" is selected on a song, showing more information about
|
||||
|
|
|
@ -34,7 +34,7 @@ import org.oxycblt.auxio.ui.recycler.Item
|
|||
import org.oxycblt.auxio.ui.recycler.MenuItemListener
|
||||
import org.oxycblt.auxio.ui.recycler.SimpleItemCallback
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.formatDurationMs
|
||||
import org.oxycblt.auxio.music.formatDurationMs
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
|
||||
|
|
|
@ -29,7 +29,7 @@ import org.oxycblt.auxio.ui.recycler.Item
|
|||
import org.oxycblt.auxio.ui.recycler.SimpleItemCallback
|
||||
import org.oxycblt.auxio.ui.recycler.SongViewHolder
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.formatDurationMs
|
||||
import org.oxycblt.auxio.music.formatDurationMs
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
|
||||
|
|
|
@ -34,8 +34,8 @@ import org.oxycblt.auxio.ui.recycler.Item
|
|||
import org.oxycblt.auxio.ui.recycler.MenuItemListener
|
||||
import org.oxycblt.auxio.ui.recycler.SyncListDiffer
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.formatDurationMs
|
||||
import org.oxycblt.auxio.util.secsToMs
|
||||
import org.oxycblt.auxio.music.formatDurationMs
|
||||
import org.oxycblt.auxio.music.secsToMs
|
||||
|
||||
/**
|
||||
* A [HomeListFragment] for showing a list of [Album]s.
|
||||
|
|
|
@ -32,7 +32,7 @@ import org.oxycblt.auxio.ui.recycler.Item
|
|||
import org.oxycblt.auxio.ui.recycler.MenuItemListener
|
||||
import org.oxycblt.auxio.ui.recycler.SyncListDiffer
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.formatDurationMs
|
||||
import org.oxycblt.auxio.music.formatDurationMs
|
||||
|
||||
/**
|
||||
* A [HomeListFragment] for showing a list of [Artist]s.
|
||||
|
|
|
@ -32,7 +32,7 @@ import org.oxycblt.auxio.ui.recycler.Item
|
|||
import org.oxycblt.auxio.ui.recycler.MenuItemListener
|
||||
import org.oxycblt.auxio.ui.recycler.SyncListDiffer
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.formatDurationMs
|
||||
import org.oxycblt.auxio.music.formatDurationMs
|
||||
|
||||
/**
|
||||
* A [HomeListFragment] for showing a list of [Genre]s.
|
||||
|
|
|
@ -36,8 +36,8 @@ import org.oxycblt.auxio.ui.recycler.SongViewHolder
|
|||
import org.oxycblt.auxio.ui.recycler.SyncListDiffer
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.formatDurationMs
|
||||
import org.oxycblt.auxio.util.secsToMs
|
||||
import org.oxycblt.auxio.music.formatDurationMs
|
||||
import org.oxycblt.auxio.music.secsToMs
|
||||
|
||||
/**
|
||||
* A [HomeListFragment] for showing a list of [Song]s.
|
||||
|
|
|
@ -23,18 +23,12 @@ import android.content.Context
|
|||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.text.format.DateUtils
|
||||
import androidx.core.text.isDigitsOnly
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import java.util.UUID
|
||||
|
||||
/** Shortcut to resolve a year from a nullable date. Will return "No Date" if it is null. */
|
||||
fun Date?.resolveYear(context: Context) =
|
||||
this?.resolveYear(context) ?: context.getString(R.string.def_date)
|
||||
|
||||
/** Converts this string to a UUID, or returns null if it is not valid. */
|
||||
fun String.toUuid() = try { UUID.fromString(this) } catch (e: IllegalArgumentException) { null }
|
||||
|
||||
/** Shortcut for making a [ContentResolver] query with less superfluous arguments. */
|
||||
fun ContentResolver.queryCursor(
|
||||
uri: Uri,
|
||||
|
@ -65,3 +59,61 @@ val Long.audioUri: Uri
|
|||
/** Converts a [Long] Album ID into a URI pointing to MediaStore-cached album art. */
|
||||
val Long.albumCoverUri: Uri
|
||||
get() = ContentUris.withAppendedId(EXTERNAL_ALBUM_ART_URI, this)
|
||||
|
||||
|
||||
/** Shortcut to resolve a year from a nullable date. Will return "No Date" if it is null. */
|
||||
fun Date?.resolveYear(context: Context) =
|
||||
this?.resolveYear(context) ?: context.getString(R.string.def_date)
|
||||
|
||||
/** Converts this string to a UUID, or returns null if it is not valid. */
|
||||
fun String.toUuid() = try { UUID.fromString(this) } catch (e: IllegalArgumentException) { null }
|
||||
|
||||
/** Converts a long in milliseconds to a long in deci-seconds */
|
||||
fun Long.msToDs() = floorDiv(100)
|
||||
|
||||
/** Converts a long in milliseconds to a long in seconds */
|
||||
fun Long.msToSecs() = floorDiv(1000)
|
||||
|
||||
/** Converts a long in deci-seconds to a long in milliseconds. */
|
||||
fun Long.dsToMs() = times(100)
|
||||
|
||||
/** Converts a long in deci-seconds to a long in seconds. */
|
||||
fun Long.dsToSecs() = floorDiv(10)
|
||||
|
||||
/** Converts a long in seconds to a long in milliseconds. */
|
||||
fun Long.secsToMs() = times(1000)
|
||||
|
||||
/**
|
||||
* Convert a [Long] of milliseconds into a string duration.
|
||||
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--
|
||||
* will be returned if the second value is 0.
|
||||
*/
|
||||
fun Long.formatDurationMs(isElapsed: Boolean) = msToSecs().formatDurationSecs(isElapsed)
|
||||
|
||||
/**
|
||||
* Convert a [Long] of deci-seconds into a string duration.
|
||||
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--
|
||||
* will be returned if the second value is 0.
|
||||
*/
|
||||
fun Long.formatDurationDs(isElapsed: Boolean) = dsToSecs().formatDurationSecs(isElapsed)
|
||||
|
||||
/**
|
||||
* Convert a [Long] of seconds into a string duration.
|
||||
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--
|
||||
* will be returned if the second value is 0.
|
||||
*/
|
||||
fun Long.formatDurationSecs(isElapsed: Boolean): String {
|
||||
if (!isElapsed && this == 0L) {
|
||||
logD("Non-elapsed duration is zero, using --:--")
|
||||
return "--:--"
|
||||
}
|
||||
|
||||
var durationString = DateUtils.formatElapsedTime(this)
|
||||
|
||||
// If the duration begins with a excess zero [e.g 01:42], then cut it off.
|
||||
if (durationString[0] == '0') {
|
||||
durationString = durationString.slice(1 until durationString.length)
|
||||
}
|
||||
|
||||
return durationString
|
||||
}
|
|
@ -45,15 +45,15 @@ import org.oxycblt.auxio.util.logW
|
|||
*
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
|
||||
class ExoPlayerBackend(private val context: Context, private val inner: MediaStoreBackend) : Indexer.Backend {
|
||||
private val settings = Settings(context)
|
||||
private val taskPool: Array<Task?> = arrayOfNulls(TASK_CAPACITY)
|
||||
|
||||
// No need to implement our own query logic, as this backend is still reliant on
|
||||
// MediaStore.
|
||||
override fun query(context: Context) = inner.query(context)
|
||||
override fun query() = inner.query()
|
||||
|
||||
override fun buildSongs(
|
||||
context: Context,
|
||||
cursor: Cursor,
|
||||
emitIndexing: (Indexer.Indexing) -> Unit
|
||||
): List<Song> {
|
||||
|
@ -82,11 +82,11 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
|
|||
if (song != null) {
|
||||
songs.add(song)
|
||||
emitIndexing(Indexer.Indexing.Songs(songs.size, total))
|
||||
taskPool[i] = Task(context, raw)
|
||||
taskPool[i] = Task(context, settings, raw)
|
||||
break@spin
|
||||
}
|
||||
} else {
|
||||
taskPool[i] = Task(context, raw)
|
||||
taskPool[i] = Task(context, settings, raw)
|
||||
break@spin
|
||||
}
|
||||
}
|
||||
|
@ -122,8 +122,7 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
|
|||
* Wraps an ExoPlayer metadata retrieval task in a safe abstraction. Access is done with [get].
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class Task(context: Context, private val raw: Song.Raw) {
|
||||
private val settings = Settings(context)
|
||||
class Task(context: Context, private val settings: Settings, private val raw: Song.Raw) {
|
||||
private val future =
|
||||
MetadataRetriever.retrieveMetadata(
|
||||
context,
|
||||
|
|
|
@ -128,8 +128,6 @@ class Indexer {
|
|||
* complete, a new completion state will be pushed to each callback.
|
||||
*/
|
||||
suspend fun index(context: Context) {
|
||||
requireBackgroundThread()
|
||||
|
||||
val handle = guard.newHandle()
|
||||
|
||||
val notGranted =
|
||||
|
@ -203,20 +201,20 @@ class Indexer {
|
|||
|
||||
val mediaStoreBackend =
|
||||
when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30MediaStoreBackend()
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Api29MediaStoreBackend()
|
||||
else -> Api21MediaStoreBackend()
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30MediaStoreBackend(context)
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Api29MediaStoreBackend(context)
|
||||
else -> Api21MediaStoreBackend(context)
|
||||
}
|
||||
|
||||
val settings = Settings(context)
|
||||
val backend =
|
||||
if (settings.useQualityTags) {
|
||||
ExoPlayerBackend(mediaStoreBackend)
|
||||
ExoPlayerBackend(context, mediaStoreBackend)
|
||||
} else {
|
||||
mediaStoreBackend
|
||||
}
|
||||
|
||||
val songs = buildSongs(context, backend, handle)
|
||||
val songs = buildSongs(backend, handle)
|
||||
if (songs.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
@ -243,16 +241,16 @@ class Indexer {
|
|||
* [buildGenres] functions must be called with the returned list so that all songs are properly
|
||||
* linked up.
|
||||
*/
|
||||
private fun buildSongs(context: Context, backend: Backend, handle: Long): List<Song> {
|
||||
private fun buildSongs(backend: Backend, handle: Long): List<Song> {
|
||||
val start = System.currentTimeMillis()
|
||||
|
||||
var songs =
|
||||
backend.query(context).use { cursor ->
|
||||
backend.query().use { cursor ->
|
||||
logD(
|
||||
"Successfully queried media database " +
|
||||
"in ${System.currentTimeMillis() - start}ms")
|
||||
|
||||
backend.buildSongs(context, cursor) { emitIndexing(it, handle) }
|
||||
backend.buildSongs(cursor) { emitIndexing(it, handle) }
|
||||
}
|
||||
|
||||
// Deduplicate songs to prevent (most) deformed music clones
|
||||
|
@ -425,11 +423,10 @@ class Indexer {
|
|||
/** Represents a backend that metadata can be extracted from. */
|
||||
interface Backend {
|
||||
/** Query the media database for a basic cursor. */
|
||||
fun query(context: Context): Cursor
|
||||
fun query(): Cursor
|
||||
|
||||
/** Create a list of songs from the [Cursor] queried in [query]. */
|
||||
fun buildSongs(
|
||||
context: Context,
|
||||
cursor: Cursor,
|
||||
emitIndexing: (Indexing) -> Unit
|
||||
): List<Song>
|
||||
|
|
|
@ -91,7 +91,6 @@ import org.oxycblt.auxio.util.logD
|
|||
* I wish I was born in the neolithic.
|
||||
*/
|
||||
|
||||
// TODO: Make context a member var to cache Settings
|
||||
// TODO: Move duration util to MusicUtil
|
||||
|
||||
/**
|
||||
|
@ -99,7 +98,7 @@ import org.oxycblt.auxio.util.logD
|
|||
* not a fully-featured class by itself, and it's API-specific derivatives should be used instead.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
abstract class MediaStoreBackend : Indexer.Backend {
|
||||
abstract class MediaStoreBackend(private val context: Context) : Indexer.Backend {
|
||||
private var idIndex = -1
|
||||
private var titleIndex = -1
|
||||
private var displayNameIndex = -1
|
||||
|
@ -113,10 +112,10 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
|||
private var artistIndex = -1
|
||||
private var albumArtistIndex = -1
|
||||
|
||||
private val settings = Settings(context)
|
||||
protected val volumes = mutableListOf<StorageVolume>()
|
||||
|
||||
override fun query(context: Context): Cursor {
|
||||
val settings = Settings(context)
|
||||
override fun query(): Cursor {
|
||||
val storageManager = context.getSystemServiceCompat(StorageManager::class)
|
||||
volumes.addAll(storageManager.storageVolumesCompat)
|
||||
val dirs = settings.getMusicDirs(storageManager)
|
||||
|
@ -162,12 +161,9 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
|||
}
|
||||
|
||||
override fun buildSongs(
|
||||
context: Context,
|
||||
cursor: Cursor,
|
||||
emitIndexing: (Indexer.Indexing) -> Unit
|
||||
): List<Song> {
|
||||
val settings = Settings(context)
|
||||
|
||||
val rawSongs = mutableListOf<Song.Raw>()
|
||||
while (cursor.moveToNext()) {
|
||||
rawSongs.add(buildRawSong(context, cursor))
|
||||
|
@ -255,8 +251,6 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
|||
* outlined in [projection].
|
||||
*/
|
||||
open fun buildRawSong(context: Context, cursor: Cursor): Song.Raw {
|
||||
val settings = Settings(context)
|
||||
|
||||
// Initialize our cursor indices if we haven't already.
|
||||
if (idIndex == -1) {
|
||||
idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
|
||||
|
@ -349,7 +343,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
|||
* A [MediaStoreBackend] that completes the music loading process in a way compatible from
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class Api21MediaStoreBackend : MediaStoreBackend() {
|
||||
class Api21MediaStoreBackend(context: Context) : MediaStoreBackend(context) {
|
||||
private var trackIndex = -1
|
||||
private var dataIndex = -1
|
||||
|
||||
|
@ -414,7 +408,7 @@ class Api21MediaStoreBackend : MediaStoreBackend() {
|
|||
* @author OxygenCobalt
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
open class BaseApi29MediaStoreBackend : MediaStoreBackend() {
|
||||
open class BaseApi29MediaStoreBackend(context: Context) : MediaStoreBackend(context) {
|
||||
private var volumeIndex = -1
|
||||
private var relativePathIndex = -1
|
||||
|
||||
|
@ -466,7 +460,7 @@ open class BaseApi29MediaStoreBackend : MediaStoreBackend() {
|
|||
* @author OxygenCobalt
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
open class Api29MediaStoreBackend : BaseApi29MediaStoreBackend() {
|
||||
open class Api29MediaStoreBackend(context: Context) : BaseApi29MediaStoreBackend(context) {
|
||||
private var trackIndex = -1
|
||||
|
||||
override val projection: Array<String>
|
||||
|
@ -497,7 +491,7 @@ open class Api29MediaStoreBackend : BaseApi29MediaStoreBackend() {
|
|||
* @author OxygenCobalt
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
class Api30MediaStoreBackend : BaseApi29MediaStoreBackend() {
|
||||
class Api30MediaStoreBackend(context: Context) : BaseApi29MediaStoreBackend(context) {
|
||||
private var trackIndex: Int = -1
|
||||
private var discIndex: Int = -1
|
||||
|
||||
|
|
|
@ -34,7 +34,7 @@ import org.oxycblt.auxio.util.androidActivityViewModels
|
|||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.getColorCompat
|
||||
import org.oxycblt.auxio.util.msToDs
|
||||
import org.oxycblt.auxio.music.msToDs
|
||||
|
||||
/**
|
||||
* A fragment showing the current playback state in a compact manner. Used as the bar for the
|
||||
|
|
|
@ -35,7 +35,7 @@ import org.oxycblt.auxio.playback.state.RepeatMode
|
|||
import org.oxycblt.auxio.ui.MainNavigationAction
|
||||
import org.oxycblt.auxio.ui.fragment.MenuFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.msToDs
|
||||
import org.oxycblt.auxio.music.msToDs
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
|
||||
|
|
|
@ -36,9 +36,9 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
|||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.application
|
||||
import org.oxycblt.auxio.util.dsToMs
|
||||
import org.oxycblt.auxio.music.dsToMs
|
||||
import org.oxycblt.auxio.util.logE
|
||||
import org.oxycblt.auxio.util.msToDs
|
||||
import org.oxycblt.auxio.music.msToDs
|
||||
|
||||
/**
|
||||
* The ViewModel that provides a UI frontend for [PlaybackStateManager].
|
||||
|
|
|
@ -22,7 +22,7 @@ import android.util.AttributeSet
|
|||
import com.google.android.material.slider.Slider
|
||||
import kotlin.math.max
|
||||
import org.oxycblt.auxio.databinding.ViewSeekBarBinding
|
||||
import org.oxycblt.auxio.util.formatDurationDs
|
||||
import org.oxycblt.auxio.music.formatDurationDs
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
|
|
|
@ -39,7 +39,7 @@ import org.oxycblt.auxio.music.Song
|
|||
import org.oxycblt.auxio.ui.fragment.ViewBindingFragment
|
||||
import org.oxycblt.auxio.util.androidActivityViewModels
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.formatDurationMs
|
||||
import org.oxycblt.auxio.music.formatDurationMs
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
|
|
|
@ -50,55 +50,6 @@ fun Int.nonZeroOrNull() = if (this > 0) this else null
|
|||
/** Returns null if this value is not in [range]. */
|
||||
fun Int.inRangeOrNull(range: IntRange) = if (range.contains(this)) this else null
|
||||
|
||||
/** Converts a long in milliseconds to a long in deci-seconds */
|
||||
fun Long.msToDs() = floorDiv(100)
|
||||
|
||||
/** Converts a long in milliseconds to a long in seconds */
|
||||
fun Long.msToSecs() = floorDiv(1000)
|
||||
|
||||
/** Converts a long in deci-seconds to a long in milliseconds. */
|
||||
fun Long.dsToMs() = times(100)
|
||||
|
||||
/** Converts a long in deci-seconds to a long in seconds. */
|
||||
fun Long.dsToSecs() = floorDiv(10)
|
||||
|
||||
/** Converts a long in seconds to a long in milliseconds. */
|
||||
fun Long.secsToMs() = times(1000)
|
||||
|
||||
/**
|
||||
* Convert a [Long] of milliseconds into a string duration.
|
||||
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--
|
||||
* will be returned if the second value is 0.
|
||||
*/
|
||||
fun Long.formatDurationMs(isElapsed: Boolean) = msToSecs().formatDurationSecs(isElapsed)
|
||||
|
||||
/**
|
||||
* Convert a [Long] of deci-seconds into a string duration.
|
||||
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--
|
||||
* will be returned if the second value is 0.
|
||||
*/
|
||||
fun Long.formatDurationDs(isElapsed: Boolean) = dsToSecs().formatDurationSecs(isElapsed)
|
||||
|
||||
/**
|
||||
* Convert a [Long] of seconds into a string duration.
|
||||
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--
|
||||
* will be returned if the second value is 0.
|
||||
*/
|
||||
fun Long.formatDurationSecs(isElapsed: Boolean): String {
|
||||
if (!isElapsed && this == 0L) {
|
||||
logD("Non-elapsed duration is zero, using --:--")
|
||||
return "--:--"
|
||||
}
|
||||
|
||||
var durationString = DateUtils.formatElapsedTime(this)
|
||||
|
||||
// If the duration begins with a excess zero [e.g 01:42], then cut it off.
|
||||
if (durationString[0] == '0') {
|
||||
durationString = durationString.slice(1 until durationString.length)
|
||||
}
|
||||
|
||||
return durationString
|
||||
}
|
||||
|
||||
/** Lazily reflect to retrieve a [Field]. */
|
||||
fun lazyReflectedField(clazz: KClass<*>, field: String) = lazy {
|
||||
|
|
Loading…
Reference in a new issue