This commit is contained in:
ocean 2024-07-18 16:57:45 +08:00
parent eb81f9bda7
commit b49547cb9b
5 changed files with 506 additions and 18 deletions

View File

@ -13,6 +13,7 @@ import melody.offline.music.databinding.MusicResponsiveItemBinding
import melody.offline.music.innertube.models.MusicCarouselShelfRenderer
import melody.offline.music.media.MediaControllerManager
import melody.offline.music.util.AnalysisUtil
import melody.offline.music.util.LogTag
class ResponsiveListAdapter(
private val context: Context,
@ -83,15 +84,20 @@ class ResponsiveListAdapter(
intent.putExtra(MoPlayDetailsActivity.PLAY_DETAILS_DESC, desc)
context.startActivity(intent)
if(itemClickListener!=null){
if (itemClickListener != null) {
itemClickListener?.onItemClick(position)
}
}
holder.binding.moreBtn.setOnClickListener {
if (itemMoreClickListener != null) {
itemMoreClickListener?.onItemMoreClick(position)
}
}
}
override fun getItemCount(): Int = list.size
inner class ViewHolder(private val binding: MusicResponsiveItemBinding) :
inner class ViewHolder(val binding: MusicResponsiveItemBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(url: String?, name: String?, desc: String?, videoID: String?) {
@ -129,4 +135,14 @@ class ResponsiveListAdapter(
interface OnItemClickListener {
fun onItemClick(position: Int)
}
private var itemMoreClickListener: OnItemMoreClickListener? = null
fun setOnItemMoreClickListener(listener: OnItemMoreClickListener) {
itemMoreClickListener = listener
}
interface OnItemMoreClickListener {
fun onItemMoreClick(position: Int)
}
}

View File

@ -1,9 +1,27 @@
package melody.offline.music.fragment
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.offline.Download
import androidx.media3.exoplayer.offline.DownloadRequest
import androidx.media3.exoplayer.offline.DownloadService
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.gyf.immersionbar.ktx.immersionBar
import kotlinx.coroutines.Dispatchers
import melody.offline.music.databinding.FragmentMoHomeBinding
import melody.offline.music.innertube.Innertube
import melody.offline.music.innertube.models.MusicCarouselShelfRenderer
@ -14,12 +32,32 @@ import melody.offline.music.view.MusicResponsiveListView
import melody.offline.music.view.MusicTowRowListView
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withContext
import melody.offline.music.App
import melody.offline.music.R
import melody.offline.music.activity.MoListDetailsActivity
import melody.offline.music.adapter.NewPlayListAdapter
import melody.offline.music.ads.AdPlacement
import melody.offline.music.ads.AnalysisAdState
import melody.offline.music.ads.LolAdWrapper
import melody.offline.music.bean.FavoriteBean
import melody.offline.music.bean.OfflineBean
import melody.offline.music.bean.Playlist
import melody.offline.music.bean.PlaylistItem
import melody.offline.music.service.MyDownloadService
import melody.offline.music.util.AnalysisUtil
import melody.offline.music.util.DownloadUtil
import melody.offline.music.util.FileSizeConverter
import melody.offline.music.util.asPlaylistItem
import melody.offline.music.view.ListMoreBottomSheetDialog
import org.json.JSONObject
class MoHomeFragment : MoBaseFragment<FragmentMoHomeBinding>() {
@OptIn(UnstableApi::class)
class MoHomeFragment : MoBaseFragment<FragmentMoHomeBinding>(),
MusicResponsiveListView.OnMoreClickListener, ListMoreBottomSheetDialog.ListMoreViewListener,
ListMoreBottomSheetDialog.UpdateAdapterListener {
interface MoHomeFragmentToSearchClickListener {
@ -31,12 +69,18 @@ class MoHomeFragment : MoBaseFragment<FragmentMoHomeBinding>() {
}
private var toSearchClickListener: MoHomeFragmentToSearchClickListener? = null
private var moreDialog: ListMoreBottomSheetDialog? = null
private val requests: Channel<Request> = Channel(Channel.UNLIMITED)
enum class Request {
TryAgain,
sealed class Request {
data object TryAgain : Request()
data class ShowDialog(val bean: MusicCarouselShelfRenderer.Content) : Request()
data class UpdateFavorite(val bean: PlaylistItem) : Request()
data class OnFavorites(val bean: PlaylistItem) : Request()
data class OnDownload(val bean: PlaylistItem) : Request()
data class OnDownloadRemove(val bean: PlaylistItem) : Request()
data class OnUpdateDownloadUi(val bean: PlaylistItem) : Request()
data class OnAddPlaylist(val bean: PlaylistItem) : Request()
}
override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentMoHomeBinding
@ -63,6 +107,166 @@ class MoHomeFragment : MoBaseFragment<FragmentMoHomeBinding>() {
Request.TryAgain -> {
initData()
}
is Request.ShowDialog -> {
moreDialog = ListMoreBottomSheetDialog(
requireActivity(),
initMoreDialogData(it.bean),
requireActivity(),
this@MoHomeFragment
)
moreDialog?.setListMoreViewListener(this@MoHomeFragment)
moreDialog?.show()
}
is Request.UpdateFavorite -> {
val currentFavoriteBean =
App.appFavoriteDBManager.getFavoriteBeanByID(it.bean.videoId)
if (currentFavoriteBean != null) {
updateFavoriteUi(currentFavoriteBean.isFavorite)
} else {
updateFavoriteUi(false)
}
}
is Request.OnFavorites -> {
val jsonObject = JSONObject()
jsonObject.put(
"song_title", it.bean.title
)
val songMap = mutableMapOf(
Pair(
AnalysisUtil.PARAM_VALUE, jsonObject.toString()
)
)
val currentFavoriteBean =
App.appFavoriteDBManager.getFavoriteBeanByID(it.bean.videoId)
if (currentFavoriteBean != null) {
currentFavoriteBean.isFavorite = !currentFavoriteBean.isFavorite
App.appFavoriteDBManager.updateFavoriteBean(currentFavoriteBean)
if (currentFavoriteBean.isFavorite) {
AnalysisUtil.logEvent(AnalysisUtil.PLAYER_B_LOVE_CLICK, songMap)
} else {
AnalysisUtil.logEvent(
AnalysisUtil.PLAYER_B_UN_LOVE_CLICK, songMap
)
}
} else {
val b = FavoriteBean(
videoId = it.bean.videoId,
title = it.bean.title,
name = it.bean.name,
thumbnail = it.bean.thumbnail,
isFavorite = true
)
App.appFavoriteDBManager.insertFavoriteBean(b)
AnalysisUtil.logEvent(AnalysisUtil.PLAYER_B_LOVE_CLICK, songMap)
}
requests.trySend(Request.UpdateFavorite(it.bean))
}
is Request.OnDownload -> {
val id = it.bean.videoId
val offBean =
App.appOfflineDBManager.getOfflineBeanByID(id)//得到当前ID的本地数据
if (offBean != null && offBean.bytesDownloaded?.let { bytes -> bytes > 0 } == true) {//判断当前数据库是否有这条数据。
showRemoveDownloadDialogHint(it.bean)
} else {
val isFavorite =
App.appFavoriteDBManager.getFavoriteBeanByID(it.bean.videoId)
//判断是否已经下载了这条数据,已经下载,就直接进行数据库数据存储,反之走下载流程。
if (DownloadUtil.downloadResourceExist(id)) {
val favoriteBean = FavoriteBean(
id,
it.bean.title,
it.bean.name,
it.bean.thumbnail,
isFavorite?.isFavorite ?: false
)
insertOfflineData(favoriteBean)
it.bean.isOffline = true//更改状态
requests.trySend(Request.OnUpdateDownloadUi(it.bean))
} else {
val downloadRequest = DownloadRequest.Builder(id, id.toUri())
.setCustomCacheKey(id).build()
val downloadCount = DownloadUtil.getCurrentDownloads()
if (downloadCount >= 3) {
Toast.makeText(
requireActivity(),
getString(R.string.download_tips),
Toast.LENGTH_LONG
).show()
} else {
DownloadService.sendAddDownload(
requireActivity(),
MyDownloadService::class.java,
downloadRequest,
false
)
LolAdWrapper.shared.showAdTiming(
requireActivity(), AdPlacement.INST_DOWNLOAD
)
val favoriteBean = FavoriteBean(
id,
it.bean.title,
it.bean.name,
it.bean.thumbnail,
isFavorite?.isFavorite ?: false
)
insertOfflineData(favoriteBean)
val jsonObject = JSONObject()
jsonObject.put(
"download_id", favoriteBean.videoId
)
val songMap = mutableMapOf(
Pair(
AnalysisUtil.PARAM_VALUE, jsonObject.toString()
)
)
AnalysisUtil.logEvent(
AnalysisUtil.PLAYER_B_DOWNLOAD_CLICK, songMap
)
LolAdWrapper.shared.loadAdIfNotCached(
requireActivity(), AdPlacement.INST_DOWNLOAD
)
}
}
}
}
is Request.OnDownloadRemove -> {
val currentOfflineBean =
App.appOfflineDBManager.getOfflineBeanByID(it.bean.videoId)
if (currentOfflineBean != null) {
App.appOfflineDBManager.deleteOfflineBean(currentOfflineBean)
it.bean.isOffline = false
}
requests.trySend(Request.OnUpdateDownloadUi(it.bean))
}
is Request.OnUpdateDownloadUi -> {
moreDialog?.updateDownloadBtnUi(it.bean.isOffline)//更新对话框的ui
}
is Request.OnAddPlaylist -> {
val isFavorite =
App.appFavoriteDBManager.getFavoriteBeanByID(it.bean.videoId) != null
showAddPlaylistBottomDialog(
FavoriteBean(
videoId = it.bean.videoId,
title = it.bean.title,
name = it.bean.name,
thumbnail = it.bean.thumbnail,
isFavorite
)
)
}
}
}
events.onReceive {
@ -97,19 +301,16 @@ class MoHomeFragment : MoBaseFragment<FragmentMoHomeBinding>() {
for (home: Innertube.HomePage in it.homePage) {
for (content: MusicCarouselShelfRenderer.Content in home.contents) {
if (content.musicResponsiveListItemRenderer != null) {
binding.contentLayout.addView(
MusicResponsiveListView(
requireActivity(),
home
)
)
val musicResponsiveListView =
MusicResponsiveListView(requireActivity(), home)
musicResponsiveListView.setOnItemMoreClickListener(this)
binding.contentLayout.addView(musicResponsiveListView)
break
}
if (content.musicTwoRowItemRenderer != null) {
binding.contentLayout.addView(
MusicTowRowListView(
requireActivity(),
home
requireActivity(), home
)
)
break
@ -135,14 +336,14 @@ class MoHomeFragment : MoBaseFragment<FragmentMoHomeBinding>() {
if (content.musicResponsiveListItemRenderer != null) {
val musicResponsiveListView =
MusicResponsiveListView(requireActivity(), home)
musicResponsiveListView.setOnItemMoreClickListener(this)
binding.contentLayout.addView(musicResponsiveListView)
break
}
if (content.musicTwoRowItemRenderer != null) {
binding.contentLayout.addView(
MusicTowRowListView(
requireActivity(),
home
requireActivity(), home
)
)
break
@ -206,4 +407,233 @@ class MoHomeFragment : MoBaseFragment<FragmentMoHomeBinding>() {
binding.noContentLayout.visibility = View.VISIBLE
}
override fun onMoreClick(bean: MusicCarouselShelfRenderer.Content) {
requests.trySend(Request.ShowDialog(bean))
}
private suspend fun initMoreDialogData(bean: MusicCarouselShelfRenderer.Content): PlaylistItem {
val watchEndpoint =
bean.musicResponsiveListItemRenderer?.flexColumns?.firstOrNull()?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull()?.navigationEndpoint?.watchEndpoint
val thumbnailUrl =
bean.musicResponsiveListItemRenderer?.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails?.let {
it.getOrNull(1) ?: it.getOrNull(0)
}?.url
val title =
bean.musicResponsiveListItemRenderer?.flexColumns?.get(0)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull()?.text
val name =
bean.musicResponsiveListItemRenderer?.flexColumns?.get(1)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull()?.text
val videoId = watchEndpoint?.videoId
LogD(TAG, "title->$title videoId->$videoId")
val offlineBean = App.appOfflineDBManager.getOfflineBeanByID(videoId ?: "")
val favoriteBean = App.appFavoriteDBManager.getFavoriteBeanByID(videoId ?: "")
return PlaylistItem(
videoId = videoId ?: "",
title = title ?: "",
name = name ?: "",
thumbnail = thumbnailUrl,
bytesDownloaded = offlineBean?.bytesDownloaded ?: 0L,
size = offlineBean?.size,
isOffline = offlineBean?.isOffline ?: false,
isFavorite = favoriteBean?.isFavorite ?: false
)
}
override fun onUpdateAdapterListener(download: Download, playlistItem: PlaylistItem) {
}
override fun onFavoritesClicked(playlistItem: PlaylistItem) {
requests.trySend(Request.OnFavorites(playlistItem))
}
override fun onDownloadClicked(playlistItem: PlaylistItem) {
requests.trySend(Request.OnDownload(playlistItem))
}
override fun onAddToPlaylistClicked(playlistItem: PlaylistItem) {
requests.trySend(Request.OnAddPlaylist(playlistItem))
}
private fun updateFavoriteUi(b: Boolean) {
if (moreDialog != null) {
moreDialog?.updateFavoriteUi(b)
}
}
private fun showRemoveDownloadDialogHint(playlistItem: PlaylistItem) {
val inflater = LayoutInflater.from(requireActivity())
val dialogView = inflater.inflate(R.layout.dialog_hint, null)
val okBtn = dialogView.findViewById<TextView>(R.id.dialog_ok_btn)
val cancelBtn = dialogView.findViewById<TextView>(R.id.dialog_cancel_btn)
val dialogBuilder = AlertDialog.Builder(requireActivity()).setView(dialogView)
val dialog = dialogBuilder.create()
dialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
dialog.show()
okBtn.setOnClickListener {
dialog.dismiss()
requests.trySend(Request.OnDownloadRemove(playlistItem))
}
cancelBtn.setOnClickListener {
dialog.dismiss()
}
}
private suspend fun insertOfflineData(favoriteBean: FavoriteBean) {
val currentDownload = DownloadUtil.getCurrentIdDownload(favoriteBean.videoId)
if (currentDownload != null) {
val bytesDownloaded = currentDownload.bytesDownloaded
val size = FileSizeConverter(currentDownload.bytesDownloaded).formattedSize()
val bean = OfflineBean(
videoId = favoriteBean.videoId,
title = favoriteBean.title,
name = favoriteBean.name,
thumbnail = favoriteBean.thumbnail,
isOffline = true,
isFavorite = favoriteBean.isFavorite,
bytesDownloaded = bytesDownloaded,
size = size
)
App.appOfflineDBManager.insertOfflineBean(bean)
} else {
val bean = OfflineBean(
videoId = favoriteBean.videoId,
title = favoriteBean.title,
name = favoriteBean.name,
thumbnail = favoriteBean.thumbnail,
isOffline = true,
isFavorite = favoriteBean.isFavorite,
)
App.appOfflineDBManager.insertOfflineBean(bean)
}
}
suspend fun showAddPlaylistBottomDialog(favoriteBean: FavoriteBean) {
val bottomAddPlaylistSheetDialog = BottomSheetDialog(requireActivity())
val view = layoutInflater.inflate(R.layout.add_playlist_layout, null)
bottomAddPlaylistSheetDialog.setContentView(view)
val newPlayListBtn = view.findViewById<LinearLayout>(R.id.newPlayListBtn)
val rv = view.findViewById<RecyclerView>(R.id.newPlayListRv)
newPlayListBtn.setOnClickListener {
bottomAddPlaylistSheetDialog.dismiss()
showNewPlaylistBottomDialog(favoriteBean)
}
// 设置对话框背景为透明以显示圆角
bottomAddPlaylistSheetDialog.window?.setBackgroundDrawableResource(android.R.color.transparent)
bottomAddPlaylistSheetDialog.window?.navigationBarColor =
ContextCompat.getColor(requireActivity(), R.color.main_bg_color)
bottomAddPlaylistSheetDialog.show()
val playlist = (App.appPlaylistDBManager.getAllPlaylists())
val adapter = NewPlayListAdapter(requireActivity(), playlist)
adapter.setOnItemClickListener(object : NewPlayListAdapter.OnItemClickListener {
override fun onItemClick(position: Int) {
launch {
val playlistItem =
App.appPlaylistDBManager.getPlaylistItems(playlist[position].id)
val isAny = playlistItem.any { it.title == favoriteBean.title }
if (isAny) {//如何这首歌曲已经存在歌单则不添加
withContext(Dispatchers.Main) {
Toast.makeText(
requireActivity(),
getString(R.string.song_exists_playlist_hint),
Toast.LENGTH_LONG
).show()
}
} else {
val isOffline =
App.appOfflineDBManager.getOfflineBeanByID(favoriteBean.videoId) != null
val isFavorite =
App.appFavoriteDBManager.getFavoriteBeanByID(favoriteBean.videoId) != null
App.appPlaylistDBManager.insertOrUpdatePlaylistItem(
PlaylistItem(
playlistId = playlist[position].id,
videoId = favoriteBean.videoId,
title = favoriteBean.title,
name = favoriteBean.name,
thumbnail = favoriteBean.thumbnail,
isOffline = isOffline,
isFavorite = isFavorite
)
)
withContext(Dispatchers.Main) {
bottomAddPlaylistSheetDialog.dismiss()
Toast.makeText(
requireActivity(),
getString(R.string.added_playlist_success_Hint),
Toast.LENGTH_LONG
).show()
}
}
}
}
})
rv.layoutManager =
LinearLayoutManager(requireActivity(), LinearLayoutManager.VERTICAL, false)
rv.adapter = adapter
}
private var bottomSheetDialog: BottomSheetDialog? = null
private fun showNewPlaylistBottomDialog(favoriteBean: FavoriteBean) {
bottomSheetDialog = BottomSheetDialog(requireActivity())
val view = layoutInflater.inflate(R.layout.new_playlist_layout, null)
bottomSheetDialog?.setContentView(view)
val edit = view.findViewById<EditText>(R.id.playlistEt)
val confirmBtn = view.findViewById<TextView>(R.id.confirmBtn)
confirmBtn.setOnClickListener {
val text = edit.text.toString().trim()
if (text.isNotEmpty()) {
launch {
val playlist = App.appPlaylistDBManager.getPlaylistByTitle(text)
if (playlist != null) {
withContext(Dispatchers.Main) {
Toast.makeText(
requireActivity(),
getString(R.string.new_playlist_duplicate_name_hint),
Toast.LENGTH_LONG
).show()
}
} else {
val newPlaylist = Playlist(title = text)
App.appPlaylistDBManager.insertOrUpdatePlaylist(newPlaylist)
withContext(Dispatchers.Main) {
if (bottomSheetDialog != null) {
bottomSheetDialog?.dismiss()
}
Toast.makeText(
requireActivity(),
getString(R.string.created_successfully),
Toast.LENGTH_LONG
).show()
}
val currentPlaylist = App.appPlaylistDBManager.getPlaylistByTitle(text)
if (currentPlaylist != null) {
val isOffline =
App.appOfflineDBManager.getOfflineBeanByID(favoriteBean.videoId) != null//返回非null则为true
val isFavorite =
App.appFavoriteDBManager.getFavoriteBeanByID(favoriteBean.videoId) != null
val playlistItem = PlaylistItem(
playlistId = currentPlaylist.id,
videoId = favoriteBean.videoId,
title = favoriteBean.title,
name = favoriteBean.name,
thumbnail = favoriteBean.thumbnail,
isOffline = isOffline,
isFavorite = isFavorite
)
App.appPlaylistDBManager.insertOrUpdatePlaylistItem(playlistItem)
}
}
}
}
}
// 设置对话框背景为透明以显示圆角
bottomSheetDialog?.window?.setBackgroundDrawableResource(android.R.color.transparent)
bottomSheetDialog?.window?.navigationBarColor =
ContextCompat.getColor(requireActivity(), R.color.main_bg_color)
bottomSheetDialog?.show()
}
}

View File

@ -49,6 +49,7 @@ class MoMeFragment : MoBaseFragment<FragmentMoMeBinding>(), NewPlayListAdapter.O
sealed class Request {
data class AddPlaylist(val text: String) : Request()
data object UpdateUi : Request()
}
override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FragmentMoMeBinding
@ -90,6 +91,10 @@ class MoMeFragment : MoBaseFragment<FragmentMoMeBinding>(), NewPlayListAdapter.O
getPlaylistData()
}
}
Request.UpdateUi -> {
fragmentOnResume()
}
}
}
@ -168,6 +173,8 @@ class MoMeFragment : MoBaseFragment<FragmentMoMeBinding>(), NewPlayListAdapter.O
super.onHiddenChanged(hidden)
if (!hidden) {
initImmersionBar()
requests.trySend(Request.UpdateUi)
}
}
@ -213,4 +220,5 @@ class MoMeFragment : MoBaseFragment<FragmentMoMeBinding>(), NewPlayListAdapter.O
bottomSheetDialog?.show()
}
}

View File

@ -9,7 +9,9 @@ import org.json.JSONObject
import melody.offline.music.R
import melody.offline.music.adapter.ResponsiveListAdapter
import melody.offline.music.innertube.Innertube
import melody.offline.music.innertube.models.MusicCarouselShelfRenderer
import melody.offline.music.util.AnalysisUtil
import melody.offline.music.util.LogTag
@SuppressLint("ViewConstructor")
class MusicResponsiveListView(context: Context, homePage: Innertube.HomePage) :
@ -33,6 +35,14 @@ class MusicResponsiveListView(context: Context, homePage: Innertube.HomePage) :
AnalysisUtil.logEvent(AnalysisUtil.HOME_B_MODULE_CLICK, map)
}
})
adapter?.setOnItemMoreClickListener(object : ResponsiveListAdapter.OnItemMoreClickListener {
override fun onItemMoreClick(position: Int) {
val bean = homePage.contents[position]
if (moreClickListener != null) {
moreClickListener?.onMoreClick(bean)
}
}
})
rv?.layoutManager = GridLayoutManager(context, 3, GridLayoutManager.HORIZONTAL, false)
rv?.adapter = adapter
}
@ -43,4 +53,14 @@ class MusicResponsiveListView(context: Context, homePage: Innertube.HomePage) :
adapter?.notifyDataSetChanged()
}
}
private var moreClickListener: OnMoreClickListener? = null
fun setOnItemMoreClickListener(listener: OnMoreClickListener) {
moreClickListener = listener
}
interface OnMoreClickListener {
fun onMoreClick(bean: MusicCarouselShelfRenderer.Content)
}
}

View File

@ -50,8 +50,9 @@
</RelativeLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center_vertical"
android:orientation="vertical">
@ -79,6 +80,19 @@
</LinearLayout>
<RelativeLayout
android:id="@+id/moreBtn"
android:layout_width="40dp"
android:layout_marginEnd="16dp"
android:visibility="visible"
android:layout_height="40dp">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:src="@drawable/three_dots_icon" />
</RelativeLayout>
</LinearLayout>
</LinearLayout>