목차
- 인트로 (완성앱 & 구현 기능 소개)
- MotionLayout 이용하여 화면 전환 UI 구성하기 (1)
- MotionLayout 이용하여 화면 전환 UI 구성하기 (2)
- 영상 목록 API 만들기
- 영상 목록 기본 구조 만들기
- MotionLayout 과 RecyclerView 사이에 스크롤 가능하게 하기(1)
- )MotionLayout 과 RecyclerView 사이에 스크롤 가능하게 하기(2)
- ExoPlayer를 이용하여 동영상 재생하기
- 마무리하기
- 아웃트로
결과화면


이 챕터를 통해 배우는 것
- MotionLayout 사용하기
- Exoplayer 사용하기
MotionLayout
- ConstraintLayout 라이브러리의 일부 (서브 클래스)
- https://developer.android.com/training/constraint-layout/motionlayout/examples?hl=ko
- 레이아웃 전환과 UI 이동, 크기 조절 및 애니메이션에 사용
- 이기정님의 파트 4, 챕터 4, OTT 앱 인트로 따라하기 에서 더 자세히 후술
ExoPlayer
- Google이 Android SDK 와 별도로 배포되는 오픈소스 프로젝트
- 오디오 및 동영상 재생 가능
- 오디오 및 동영상 재생 관련 강력한 기능들 포함
- 유튜브 앱에서 사용하는 라이브러리
- https://exoplayer.dev/hello-world.html
Youtube
Retrofit 을 이용하여 영상 목록을 받아와 구성함
MotionLayout 을 이용하여 유튜브 영상 플레이어 화면전환 애니메이션을 구현함.
영상 목록을 클릭하여 ExoPlayer 를 이용하여 영상을 재생할 수 있음.

1) MotionLayout 사용하기
유튜브 카피 앱 만들기 과정을 진행하면서 공부가 더 필요했던 부분이 바로 모션레이아웃이다 위의 앱과 같이 앱의 홈화면을 잡아당기며 유튜브영상의 크기를 줄이고 늘리는 과정에는 MotionLayout을 통해 가능하였다.
먼저 MainActivity.xml 레아웃을 설정해준다. 초기 Center에는 리사이클러뷰를 넣어주어 Retrofit2을 활용한 데이터들이 들어갈 수 있도록 만들어준다. 그리고 Fragment bottom navigation을 설정하여 Home(홈)을 만들어준다. 이 Fragment가 들어갈 수 있도록 FrameLayout을 설정해준다 그리고 마지막으로 가장중요한 mainActivity.xml을 motionLayout 형태로 고쳐준다. 그러면 아래와 같이 motionLayout을 사용할 수 있다.

motionLayout은 각각의 view들의 움직임을 조절할 수있으며 start와 end에 설정값을 넣어주어 움직임이 시작될때와 끝날때의 값을 조절할 수 있다. 아래와 같이 create Constraint를 통해 조절이 가능하다.

원하는 view나 bar를 설정해주고 create Constraint를 해준다. 처음 bottomnavigationBar의 translationY는 default 값으로 0이들어가 있다 하지만 아래와 같이 create Constraint 이후 res - xml - activity_main_scene.xml에 있는 부분에
<ConstraintSet android:id="@+id/end">
<Constraint
android:translationY="56dp"
android:id="@+id/mainBottomNavigationView"
motion:layout_constraintEnd_toEndOf="parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintStart_toStartOf="parent" />
</ConstraintSet>
translationY를 56dp를 설정해주면 end시 즉 모션이 끝날때 home fragment는 안보이게 된다. 그리고 fragmentContainer 또한 startf를 아래와 같이 create Constraint설정을 해준다.
<Constraint
android:id="@+id/fragmentContainer"
android:layout_width="0dp"
android:layout_height="0dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintHorizontal_bias="0.0"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent"
motion:layout_constraintVertical_bias="0.0" />
Bias와 관련된 제약은 크게 두 개입니다. 수평(Horizontal) 방향에 대한 Bias와 수직(Vertical) 방향에 대한 Bias.
* layout_constraintHorizontal_bias - 수평 방향(Left/Right 또는 Start/End) 사이드 제약 시, 양 사이드 간 위치 비율.
> 0 이상의 소수점 값 사용 가능. (예. 0.1, 0.65, 0.821)
> 뷰의 왼쪽(또는 시작) 사이드 제약 위치가 0의 기준.
> 뷰의 오른쪽(또는 끝) 사이드 제약 위치가 1의 기준.
> 기본 값은 0.5(수평 방향 가운데 위치).
* layout_constraintVertical_bias - 수직 방향(Top/Bottom) 사이드 제약 시, 양 사이드 간 위치 비율.
> 0 이상의 소수점 값 사용 가능. (예. 0.1, 0.65, 0.821)
> 뷰의 위 사이드 제약 위치가 0의 기준.
> 뷰의 아래 사이드 제약 위치가 1의 기준.
> 기본 값은 0.5(수직 방향 가운데 위치).

horizontail_bias(가장 왼쪽에 위치 : 0) constaintVertical_bias(가장 위쪽에 위치 0) 둘다 0으로 해주었으므로 왼쪽 상단에 위치시킨다.
2) MainActivity 유튜브 동영상 목록 불러오기(RecyclerVeiw 설정)
mainActivity.xml 설정은 모두 끝이났다. mainAcitivity에 retrofit2을 이용해 json Api를 불러와 데이터를 가져온후 RecyclerView를 이용해 데이터들이 보이도록 설정을해보자.
mocky.io를 통해 dummy data를 저장할 수 있는 json형태의 api를 만들어 주었다. 그러면
https://run.mocky.io/v3/cbb293eb-e8b2-4079-ba82-472d1c0419d1

이런형태의 json이 위와 같은 주소의 api로 불러올 수 있다. 그리고 mainacitvity에 retrofit을 이용해 불러와 준다.
먼저 app수준의 build.gradle에
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
이두가지 요소를 sync now 해주고
private fun getVideoList() {
val retrofit = Retrofit.Builder()
.baseUrl("https://run.mocky.io/")
.addConverterFactory(GsonConverterFactory.create())
.build()
retrofit.create(VideoService::class.java).also {
it.listVideos()
.enqueue(object : Callback<VideoDto> {
override fun onResponse(call: Call<VideoDto>, response: Response<VideoDto>) {
if (response.isSuccessful.not()) {
Log.d("this", "실패")
return
}
response.body()?.let {
VideoDto -> videoAdapter.submitList(VideoDto.videos)
}
}
override fun onFailure(call: Call<VideoDto>, t: Throwable) {
// 예외처리
}
})
}
}
package com.example.aop_part4_chaptor01.service
import com.example.aop_part4_chaptor01.dto.VideoDto
import retrofit2.Call
import retrofit2.http.GET
interface VideoService {
@GET("/v3/cbb293eb-e8b2-4079-ba82-472d1c0419d1")
fun listVideos(): Call<VideoDto>
}
// Retrofi을 사용하기 위함
package com.example.aop_part4_chaptor01.model
data class VideoModel(
val title: String,
val sources: String,
val subtitle: String,
val thumb: String,
val description: String
)
package com.example.aop_part4_chaptor01.dto
import com.example.aop_part4_chaptor01.model.VideoModel
data class VideoDto(
val videos: List<VideoModel>
)
retrofit을 위한 videoservice 인터페이스를 제작해준다. 여기서는 총 5개의 데이터를 가져와야 하기 때문에 위 같이 모델을 만들어주고 dto를 만들어주어 dto를 사용가능하도록 InterFace를 통해 Call 해준다
이렇게 받아온 data를 RecyclerView를 이용해 data를 mainactivity에 뿌려주기 위해서는 adapter가 필요하다 따라서
private lateinit var videoAdapter : VideoAdapter
videoAdapter를 설정해주고 각각의 view data들을 클릭하면 model의 url과 title이

좌측으로 src형태로 보이고 우측에는 title이 보여야 하므로 아래와 같이 adpater로 전달해준다.
videoAdapter = VideoAdapter(callback = {
url, title -> supportFragmentManager.fragments.find { it is PlayerFragment }?.let {
(it as PlayerFragment).play(url, title)
}
videoAapter 소스코드
package com.example.aop_part4_chaptor01.adaptor
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.example.aop_part4_chaptor01.R
import com.example.aop_part4_chaptor01.model.VideoModel
class VideoAdapter(val callback: (String,String) -> Unit) : androidx.recyclerview.widget.ListAdapter<VideoModel, VideoAdapter.ViewHolder>(diffUtil){
inner class ViewHolder(val view: View) : RecyclerView.ViewHolder(view){
fun bind(item: VideoModel){
val titleTextView = view.findViewById<TextView>(R.id.titleTextView)
val subTitleTextView = view.findViewById<TextView>(R.id.subTitleTextView)
val thumbnailImageView = view.findViewById<ImageView>(R.id.thumbnailImageView)
titleTextView.text = item.title
subTitleTextView.text = item.subtitle
Glide.with(thumbnailImageView.context)
.load(item.thumb)
.into(thumbnailImageView)
view.setOnClickListener {
callback(item.sources, item.title)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_video, parent, false))
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
return holder.bind(currentList[position])
}
companion object{
val diffUtil = object : DiffUtil.ItemCallback<VideoModel>() {
override fun areItemsTheSame(oldItem: VideoModel, newItem: VideoModel): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: VideoModel, newItem: VideoModel): Boolean {
return oldItem == newItem
}
}
}
}
연속된 RecyclerView가 그려지기 위한 과정 정리
1. 연속되는 view가 그려지기 위해 한개의 임의의 xml layout View를 만들어 준다. 이 xml layout view가 반복돼서 재사용 되게 되는 것이다.
2. 그 다음 Retrofit2을 이용해 데이터를 List형태로 담긴 ViewModel을 가지고 온다. 그리고 response.body()를 생성된 어뎁터로 전달해준다
3. adapter는 OncreateViewHolder를 실행시켜 viewholder에 우리가 첫번째에 만든 xml layout View에 layoutInflater를 통해 전달해준다.
4. onBindViewHolder가 실행되고 holder.bind(currentList[Position])으로 각 포지션에 할당된 데이터 값을 넣어주어 veiw를 보여준다.
5. inner class로 정의된 viewholder는 onBindViewHolder에서 return된 holder.bind를 통해 currentList의 아이템들을 각각 전달 해준다.
6. viewholder inner class에 정의된 bind() 함수가 실행이되고 각각의 viewModel data를 매칭시켜준다. ImageView같은경우는 Glide를 통해 넣어준다.
Glide.with(thumbnailImageView.context)
.load(item.thumb)
.into(thumbnailImageView)
7. diffutill을 통해 중복 recyclerview를 방지하는 역할도 추가가 됐다.
companion object{
val diffUtil = object : DiffUtil.ItemCallback<VideoModel>() {
override fun areItemsTheSame(oldItem: VideoModel, newItem: VideoModel): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: VideoModel, newItem: VideoModel): Boolean {
return oldItem == newItem
}
}
}
8. view를 클릭하는 setonClickListener 이벤트를 통해 callback 함수로 item의 Sources와 title을 전달해주고 adpater를 마무리 해준다.
2) PlayerFragment 설정하기(fragment는 하단부에 있다가 동영상 재생시 보여짐)
MotionLayout은 앱에서 모션과 위젯 애니메이션을 관리하는 데 사용할 수 있는 레이아웃 유형입니다. MotionLayout은 ConstraintLayout의 서브클래스이며 ConstraintLayout의 다양한 레이아웃 기능을 기초로 합니다. ConstraintLayout 라이브러리의 일부인 MotionLayout은 지원 라이브러리로 사용 가능하며, API 레벨 14와 호환됩니다.

그림 1. 기본 터치 컨트롤 모션입니다.
MotionLayout은 레이아웃 전환과 복잡한 모션 처리 사이를 연결하며 속성 애니메이션 프레임워크, TransitionManager 및 CoordinatorLayout 사이의 혼합된 기능을 제공합니다.
레이아웃 사이의 전환 외에도 MotionLayout을 사용하여 레이아웃 속성을 애니메이션으로 보여줄 수 있습니다. 또한 기본적으로 검색 가능 전환을 지원합니다. 즉, 터치 입력과 같은 일부 조건에 따라 전환 내의 포인트를 즉시 표시할 수 있습니다. MotionLayout에서는 키프레임도 지원하므로 사용자의 필요에 맞게 완전히 맞춤설정된 전환을 사용할 수 있습니다.
MotionLayout은 완전히 선언 가능하므로, 복잡도에 상관없이 XML로 모든 전환을 설명할 수 있습니다.
각각 activity에 만들어진 motionLayout을 붙여야 하는 경우가 있는데 그때바로 setTranstitionListner를 이용한다.
private fun initMotionLayoutEvent(fragmentPlayerBinding: FragmentPlayerBinding) {
fragmentPlayerBinding.playerMotionLayout.setTransitionListener(object :
MotionLayout.TransitionListener {
override fun onTransitionStarted(motionLayout: MotionLayout?, startId: Int, endId: Int, ) {}
override fun onTransitionChange(motionLayout: MotionLayout?, startId: Int, endId: Int, progress: Float, ) {
binding?.let {
(activity as MainActivity).also { // MainActivty 인지 검사 ~!
mainActivity ->
mainActivity.findViewById<MotionLayout>(R.id.mainMotionLayout).progress =
abs(progress)
}
}
}
override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) {}
override fun onTransitionTrigger(motionLayout: MotionLayout?, triggerId: Int, positive: Boolean, progress: Float, ) {}
})
}
위와같이 setTranstitionListner를 설정하면 object를 통해 총 4개의 모션이벤트를 가져올 수 있다 여기서 사용할 이벤트는 onTransitionChange이다. 제목그대로 모션레이아웃이 change됐을때 작동하는 리스너 과정으로 현재 playFragment Activity의 모션레이아웃이 동작할때 mainActivity에 모션레이아웃또한 그에 따라 동작하도록 하였다.
추가 MotionLayout 속성
위의 예에 있는 속성 외에도 MotionLayout에는 다음과 같이 지정할 수 있는 다른 속성이 있습니다.
- app:applyMotionScene="boolean"은 MotionScene 적용 여부를 나타냅니다. 이 속성의 기본값은 true입니다.
- app:showPaths="boolean"은 모션이 실행 중일 때 모션 경로를 표시할지 나타냅니다. 이 속성의 기본값은 false입니다.
- app:progress="float"를 사용하면 전환 진행 상황을 명시적으로 지정할 수 있습니다. 0(전환 시작)부터 1(전환 종료)까지 부동 소수점 값을 사용할 수 있습니다.
- app:currentState="reference"를 사용하면 특정 ConstraintSet를 지정할 수 있습니다.
- app:motionDebug를 사용하면 모션에 관한 추가 디버그 정보를 표시할 수 있습니다. 가능한 값은 'SHOW_PROGRESS', 'SHOW_PATH' 또는 'SHOW_ALL'입니다.
3) Kotlin Exo Player 사용하기
https://exoplayer.dev/hello-world.html
Hello world! - ExoPlayer
exoplayer.dev
Exoplayer 공식 홈페이지,
package com.example.aop_part4_chaptor01
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.aop_part4_chaptor01.adaptor.VideoAdapter
import com.example.aop_part4_chaptor01.databinding.FragmentPlayerBinding
import com.example.aop_part4_chaptor01.dto.VideoDto
import com.example.aop_part4_chaptor01.service.VideoService
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import kotlin.math.abs
class PlayerFragment : Fragment(R.layout.fragment_player) {
private lateinit var videoAdapter: VideoAdapter
private var binding: FragmentPlayerBinding? = null
private var player: SimpleExoPlayer? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val fragmentPlayerBinding = FragmentPlayerBinding.bind(view)
binding = fragmentPlayerBinding
initMotionLayoutEvent(fragmentPlayerBinding)
initRecyclerView(fragmentPlayerBinding)
initPlayer(fragmentPlayerBinding)
initControllButton(fragmentPlayerBinding)
getVideoList()
}
private fun initControllButton(fragmentPlayerBinding: FragmentPlayerBinding) {
fragmentPlayerBinding.bottomPlayerControlButton.setOnClickListener {
val player = this.player ?: return@setOnClickListener // null일경우 그냥 리턴!
if (player.isPlaying){
player.pause()
}
else{
player.play()
}
}
}
private fun initRecyclerView(fragmentPlayerBinding: FragmentPlayerBinding) {
videoAdapter = VideoAdapter(callback = { url, title ->
play(url, title)
})
fragmentPlayerBinding.fragmentRecyclerView.apply {
adapter = videoAdapter
layoutManager = LinearLayoutManager(context)
}
}
private fun initPlayer(fragmentPlayerBinding: FragmentPlayerBinding) {
context?.let {
player = SimpleExoPlayer.Builder(it).build()
}
fragmentPlayerBinding.playerView.player = player
binding?.let {
player?.addListener(object : Player.EventListener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
if (isPlaying) {
it.bottomPlayerControlButton.setImageResource(R.drawable.pause)
} else {
it.bottomPlayerControlButton.setImageResource(R.drawable.play)
}
}
})
}
}
private fun getVideoList() {
val retrofit = Retrofit.Builder()
.baseUrl("https://run.mocky.io/")
.addConverterFactory(GsonConverterFactory.create())
.build()
retrofit.create(VideoService::class.java).also {
it.listVideos()
.enqueue(object : Callback<VideoDto> {
override fun onResponse(call: Call<VideoDto>, response: Response<VideoDto>) {
if (response.isSuccessful.not()) {
Log.d("this", "실패")
return
}
response.body()?.let { VideoDto ->
videoAdapter.submitList(VideoDto.videos)
}
}
override fun onFailure(call: Call<VideoDto>, t: Throwable) {
// 예외처리
Log.d("this", "실패 Fail!")
return
}
})
}
}
override fun onDestroy() {
super.onDestroy()
binding = null
}
private fun initMotionLayoutEvent(fragmentPlayerBinding: FragmentPlayerBinding) {
fragmentPlayerBinding.playerMotionLayout.setTransitionListener(object :
MotionLayout.TransitionListener {
override fun onTransitionStarted(motionLayout: MotionLayout?, startId: Int, endId: Int, ) {}
override fun onTransitionChange(motionLayout: MotionLayout?, startId: Int, endId: Int, progress: Float, ) {
binding?.let {
(activity as MainActivity).also { // MainActivty 인지 검사 ~!
mainActivity ->
mainActivity.findViewById<MotionLayout>(R.id.mainMotionLayout).progress =
abs(progress)
}
}
}
override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) {}
override fun onTransitionTrigger(motionLayout: MotionLayout?, triggerId: Int, positive: Boolean, progress: Float, ) {}
})
}
fun play(url: String, title: String) {
context?.let {
val dataSourceFactory = DefaultDataSourceFactory(it)
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(Uri.parse(url)))
player?.setMediaSource(mediaSource)
player?.prepare()
player?.play()
}
binding?.let {
it.playerMotionLayout.transitionToEnd()
it.bottomTitleTextView.text = title
}
}
override fun onStop() {
super.onStop()
player?.pause()
}
override fun onDestroyView() {
super.onDestroyView()
binding = null
player?.release()
}
}
현재 이 play()함수는 callback형태로 어뎁터로 전달돼있고 각 데이터를 클릭할시 play()함수가 실행돼서 player가 play가 된다.
마지막으로 custom한 형태의 MotionLayout을 설정해준다. 아래의 코드는 아래의 영역 즉 원하는 특정 영역만 클릭하여 모션레이아웃이 설정되게하는 customLayout형태이고 메인화면의 recyclerview나 다른 작업을 클릭할 수 있다. 그리고 이렇게 내가 만든 CustomMotionLayout이 fragment_player.xml 레이아웃에 사용가능 하다.
package com.example.aop_part4_chaptor01
import android.content.Context
import android.graphics.Rect
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import androidx.constraintlayout.motion.widget.MotionLayout
class CustomMotionLayout(context: Context, attributeSet: AttributeSet? = null): MotionLayout(context, attributeSet) {
private var motionTouchStarted = false
private val mainContainerView by lazy {
findViewById<View>(R.id.mainContaiverLayout)
}
private val hitRect = Rect()
init {
setTransitionListener(object: TransitionListener {
override fun onTransitionStarted(p0: MotionLayout?, p1: Int, p2: Int) {}
override fun onTransitionChange(p0: MotionLayout?, p1: Int, p2: Int, p3: Float) {}
override fun onTransitionCompleted(p0: MotionLayout?, p1: Int) {
motionTouchStarted = false
}
override fun onTransitionTrigger(p0: MotionLayout?, p1: Int, p2: Boolean, p3: Float) {}
})
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
motionTouchStarted = false
return super.onTouchEvent(event)
// ACTION_UO은 손가락을땔때, ACTION_CANCLE -> 빼고 보도록 하자!! 우리가 상관할 필요없는 기능이다.
}
}
if (!motionTouchStarted) {
mainContainerView.getHitRect(hitRect) // 클릭하고자 하는 뷰의 터치 영역을 hitrect에 저장한다.
motionTouchStarted = hitRect.contains(event.x.toInt(), event.y.toInt())
// motion Event의 좌표가 이영역안에서 일어나는 제대로된 이벤트인가를 판단하는 과정 True False로 반환이된다.
}
return super.onTouchEvent(event) && motionTouchStarted
}
private val gestureListener by lazy {
object: GestureDetector.SimpleOnGestureListener() {
override fun onScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
mainContainerView.getHitRect(hitRect) // mainContainerView에서 일어난 것인지 ?
return hitRect.contains(e1.x.toInt(), e1.y.toInt())
}
}
}
private val gestureDetector by lazy {
GestureDetector(context, gestureListener)
}
override fun onInterceptTouchEvent(event: MotionEvent?): Boolean {
return gestureDetector.onTouchEvent(event)
}
}'안드로이드 > 앱개발(Android)' 카테고리의 다른 글
| (Android) OTT 앱 (0) | 2022.03.08 |
|---|---|
| (Android) 음악스트리밍 앱 (0) | 2022.02.20 |
| (Android) 에어비엔비앱 (0) | 2022.02.12 |
| (Android) 중고거래 앱 (0) | 2022.02.12 |
| (Android) 틴더앱 (0) | 2022.02.12 |