remove ExoPlayer related things from the VideoFragment

This commit is contained in:
tibbi 2019-01-02 19:00:16 +01:00
parent 1e39784324
commit 1df4ae6d0b
3 changed files with 30 additions and 539 deletions

View File

@ -20,7 +20,6 @@ import android.os.Bundle
import android.os.Handler
import android.provider.MediaStore
import android.util.DisplayMetrics
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
@ -42,7 +41,6 @@ import com.simplemobiletools.gallery.pro.dialogs.SaveAsDialog
import com.simplemobiletools.gallery.pro.dialogs.SlideshowDialog
import com.simplemobiletools.gallery.pro.extensions.*
import com.simplemobiletools.gallery.pro.fragments.PhotoFragment
import com.simplemobiletools.gallery.pro.fragments.VideoFragment
import com.simplemobiletools.gallery.pro.fragments.ViewPagerFragment
import com.simplemobiletools.gallery.pro.helpers.*
import com.simplemobiletools.gallery.pro.models.Medium
@ -497,8 +495,6 @@ class ViewPagerActivity : SimpleActivity(), ViewPager.OnPageChangeListener, View
swipeToNextMedium()
}
}, mSlideshowInterval * 1000L)
} else {
(getCurrentFragment() as? VideoFragment)!!.playVideo()
}
}
}
@ -512,10 +508,6 @@ class ViewPagerActivity : SimpleActivity(), ViewPager.OnPageChangeListener, View
(config.slideshowIncludePhotos && it.isImage()) || (config.slideshowIncludeGIFs && it.isGIF())
}.toMutableList()
mSlideshowMedia.forEach {
Log.e("DEBUG", "got ${it.name}")
}
if (config.slideshowRandomOrder) {
mSlideshowMedia.shuffle()
mPos = 0

View File

@ -2,73 +2,43 @@ package com.simplemobiletools.gallery.pro.fragments
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Point
import android.graphics.SurfaceTexture
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.util.DisplayMetrics
import android.view.*
import android.view.animation.AnimationUtils
import android.widget.SeekBar
import android.widget.TextView
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.bumptech.glide.Glide
import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
import com.google.android.exoplayer2.source.ExtractorMediaSource
import com.google.android.exoplayer2.source.TrackGroupArray
import com.google.android.exoplayer2.trackselection.TrackSelectionArray
import com.google.android.exoplayer2.upstream.ContentDataSource
import com.google.android.exoplayer2.upstream.DataSource
import com.google.android.exoplayer2.upstream.DataSpec
import com.google.android.exoplayer2.upstream.FileDataSource
import com.google.android.exoplayer2.video.VideoListener
import com.simplemobiletools.commons.extensions.*
import com.simplemobiletools.gallery.pro.BuildConfig
import com.simplemobiletools.gallery.pro.R
import com.simplemobiletools.gallery.pro.activities.PanoramaVideoActivity
import com.simplemobiletools.gallery.pro.activities.VideoActivity
import com.simplemobiletools.gallery.pro.extensions.*
import com.simplemobiletools.gallery.pro.helpers.*
import com.simplemobiletools.gallery.pro.activities.VideoPlayerActivity
import com.simplemobiletools.gallery.pro.extensions.config
import com.simplemobiletools.gallery.pro.extensions.navigationBarWidth
import com.simplemobiletools.gallery.pro.extensions.portrait
import com.simplemobiletools.gallery.pro.extensions.realScreenSize
import com.simplemobiletools.gallery.pro.helpers.IsoTypeReader
import com.simplemobiletools.gallery.pro.helpers.MEDIUM
import com.simplemobiletools.gallery.pro.helpers.PATH
import com.simplemobiletools.gallery.pro.models.Medium
import com.simplemobiletools.gallery.pro.views.MediaSideScroll
import kotlinx.android.synthetic.main.bottom_video_time_holder.view.*
import kotlinx.android.synthetic.main.pager_video_item.view.*
import java.io.File
import java.io.FileInputStream
import java.nio.ByteBuffer
import java.nio.channels.FileChannel
class VideoFragment : ViewPagerFragment(), TextureView.SurfaceTextureListener, SeekBar.OnSeekBarChangeListener {
private val PROGRESS = "progress"
class VideoFragment : ViewPagerFragment() {
private val FILE_CHANNEL_CONTAINERS = arrayListOf("moov", "trak", "mdia", "minf", "udta", "stbl")
private var mTextureView: TextureView? = null
private var mCurrTimeView: TextView? = null
private var mSeekBar: SeekBar? = null
private var mExoPlayer: SimpleExoPlayer? = null
private var mVideoSize = Point(0, 0)
private var mTimerHandler = Handler()
private var mHidePlayPauseHandler = Handler()
private var mIsPlaying = false
private var mIsDragged = false
private var mIsFullscreen = false
private var mIsFragmentVisible = false
private var mWasFragmentInit = false
private var mIsPanorama = false
private var mWasVideoStarted = false
private var mCurrTime = 0
private var mDuration = 0
private var mStoredShowExtendedDetails = false
private var mStoredHideExtendedDetails = false
private var mStoredBottomActions = true
private var mStoredExtendedDetails = 0
private var mStoredRememberLastVideoPosition = false
private var mStoredLastVideoPath = ""
private var mStoredLastVideoPosition = 0
private lateinit var mTimeHolder: View
private lateinit var mBrightnessSideScroll: MediaSideScroll
private lateinit var mVolumeSideScroll: MediaSideScroll
@ -79,30 +49,19 @@ class VideoFragment : ViewPagerFragment(), TextureView.SurfaceTextureListener, S
mView = inflater.inflate(R.layout.pager_video_item, container, false).apply {
instant_prev_item.setOnClickListener { listener?.goToPrevItem() }
instant_next_item.setOnClickListener { listener?.goToNextItem() }
video_curr_time.setOnClickListener { skip(false) }
video_duration.setOnClickListener { skip(true) }
video_holder.setOnClickListener { toggleFullscreen() }
video_preview.setOnClickListener { toggleFullscreen() }
panorama_outline.setOnClickListener { openPanorama() }
video_play_outline.setOnClickListener { launchVideoPlayer() }
// adding an empty click listener just to avoid ripple animation at toggling fullscreen
video_seekbar.setOnClickListener { }
mTimeHolder = video_time_holder
mBrightnessSideScroll = video_brightness_controller
mVolumeSideScroll = video_volume_controller
mCurrTimeView = video_curr_time
if (context.config.allowDownGesture) {
video_preview.setOnTouchListener { v, event ->
handleEvent(event)
false
}
video_surface.setOnTouchListener { v, event ->
handleEvent(event)
false
}
}
}
@ -110,46 +69,22 @@ class VideoFragment : ViewPagerFragment(), TextureView.SurfaceTextureListener, S
medium = arguments!!.getSerializable(MEDIUM) as Medium
Glide.with(context!!).load(medium.path).into(mView.video_preview)
// setMenuVisibility is not called at VideoActivity (third party intent)
if (!mIsFragmentVisible && activity is VideoActivity) {
mIsFragmentVisible = true
}
mIsFullscreen = activity!!.window.decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_FULLSCREEN == View.SYSTEM_UI_FLAG_FULLSCREEN
initTimeHolder()
checkIfPanorama()
medium.path.getVideoResolution()?.apply {
mVideoSize.x = x
mVideoSize.y = y
if (mIsPanorama) {
mView.apply {
panorama_outline.beVisible()
video_play_outline.beGone()
mVolumeSideScroll.beGone()
mBrightnessSideScroll.beGone()
Glide.with(context!!).load(medium.path).into(video_preview)
}
if (mIsPanorama) {
mView.apply {
panorama_outline.beVisible()
video_play_outline.beGone()
mVolumeSideScroll.beGone()
mBrightnessSideScroll.beGone()
Glide.with(context!!).load(medium.path).into(video_preview)
}
}
if (!mIsPanorama) {
setupPlayer()
if (savedInstanceState != null) {
mCurrTime = savedInstanceState.getInt(PROGRESS)
}
checkFullscreen()
mWasFragmentInit = true
mExoPlayer = ExoPlayerFactory.newSimpleInstance(context)
mExoPlayer!!.seekParameters = SeekParameters.CLOSEST_SYNC
initExoPlayerListeners()
if (mVideoSize.x != 0 && mVideoSize.y != 0) {
setVideoSize()
}
mView.apply {
mBrightnessSideScroll.initialize(activity!!, slide_info, true, container) { x, y ->
video_holder.performClick()
@ -158,22 +93,10 @@ class VideoFragment : ViewPagerFragment(), TextureView.SurfaceTextureListener, S
mVolumeSideScroll.initialize(activity!!, slide_info, false, container) { x, y ->
video_holder.performClick()
}
video_surface.onGlobalLayout {
if (mIsFragmentVisible && context?.config?.autoplayVideos == true) {
playVideo()
}
}
}
}
setupVideoDuration()
if (mStoredRememberLastVideoPosition) {
setLastVideoSavedPosition()
}
updateInstantSwitchWidths()
return mView
}
@ -195,47 +118,16 @@ class VideoFragment : ViewPagerFragment(), TextureView.SurfaceTextureListener, S
checkExtendedDetails()
}
if (config.bottomActions != mStoredBottomActions) {
initTimeHolder()
}
mTimeHolder.setBackgroundResource(if (config.bottomActions) 0 else R.drawable.gradient_background)
storeStateVariables()
}
override fun onPause() {
super.onPause()
pauseVideo()
if (mStoredRememberLastVideoPosition && mIsFragmentVisible && mWasVideoStarted) {
saveVideoProgress()
}
storeStateVariables()
}
override fun onDestroy() {
super.onDestroy()
if (activity?.isChangingConfigurations == false) {
cleanup()
}
}
override fun setMenuVisibility(menuVisible: Boolean) {
super.setMenuVisibility(menuVisible)
if (mIsFragmentVisible && !menuVisible) {
pauseVideo()
}
mIsFragmentVisible = menuVisible
if (mWasFragmentInit && menuVisible && context?.config?.autoplayVideos == true) {
playVideo()
}
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
setVideoSize()
initTimeHolder()
checkExtendedDetails()
updateInstantSwitchWidths()
}
@ -246,363 +138,27 @@ class VideoFragment : ViewPagerFragment(), TextureView.SurfaceTextureListener, S
mStoredHideExtendedDetails = hideExtendedDetails
mStoredExtendedDetails = extendedDetails
mStoredBottomActions = bottomActions
mStoredRememberLastVideoPosition = rememberLastVideoPosition
mStoredLastVideoPath = lastVideoPath
mStoredLastVideoPosition = lastVideoPosition
}
}
private fun setupPlayer() {
if (activity == null)
private fun launchVideoPlayer() {
val newUri = activity!!.getFinalUriFromPath(medium.path, BuildConfig.APPLICATION_ID)
if (newUri == null) {
context!!.toast(R.string.unknown_error_occurred)
return
mView.video_play_outline.setOnClickListener { togglePlayPause() }
mTextureView = mView.video_surface
mTextureView!!.setOnClickListener { toggleFullscreen() }
mTextureView!!.surfaceTextureListener = this
checkExtendedDetails()
}
private fun saveVideoProgress() {
if (!videoEnded()) {
mStoredLastVideoPosition = mExoPlayer!!.currentPosition.toInt() / 1000
mStoredLastVideoPath = medium.path
}
context!!.config.apply {
lastVideoPosition = mStoredLastVideoPosition
lastVideoPath = mStoredLastVideoPath
val mimeType = activity!!.getUriMimeType(medium.path, newUri)
Intent(activity, VideoPlayerActivity::class.java).apply {
setDataAndType(newUri, mimeType)
context!!.startActivity(this)
}
}
private fun initExoPlayer() {
val isContentUri = medium.path.startsWith("content://")
val uri = if (isContentUri) Uri.parse(medium.path) else Uri.fromFile(File(medium.path))
val dataSpec = DataSpec(uri)
val fileDataSource = if (isContentUri) ContentDataSource(context) else FileDataSource()
try {
fileDataSource.open(dataSpec)
} catch (e: Exception) {
activity?.showErrorToast(e)
}
val factory = DataSource.Factory { fileDataSource }
val audioSource = ExtractorMediaSource(fileDataSource.uri, factory, DefaultExtractorsFactory(), null, null)
mExoPlayer!!.audioStreamType = C.STREAM_TYPE_MUSIC
mExoPlayer!!.prepare(audioSource)
}
private fun initExoPlayerListeners() {
mExoPlayer!!.addListener(object : Player.EventListener {
override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters?) {}
override fun onSeekProcessed() {}
override fun onTracksChanged(trackGroups: TrackGroupArray?, trackSelections: TrackSelectionArray?) {}
override fun onPlayerError(error: ExoPlaybackException?) {
}
override fun onLoadingChanged(isLoading: Boolean) {}
override fun onPositionDiscontinuity(reason: Int) {}
override fun onRepeatModeChanged(repeatMode: Int) {}
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {}
override fun onTimelineChanged(timeline: Timeline?, manifest: Any?, reason: Int) {}
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
when (playbackState) {
Player.STATE_READY -> videoPrepared()
Player.STATE_ENDED -> videoCompleted()
}
}
})
mExoPlayer!!.addVideoListener(object : VideoListener {
override fun onVideoSizeChanged(width: Int, height: Int, unappliedRotationDegrees: Int, pixelWidthHeightRatio: Float) {
mVideoSize.x = width
mVideoSize.y = height
setVideoSize()
}
override fun onRenderedFirstFrame() {}
})
}
private fun toggleFullscreen() {
listener?.fragmentClicked()
}
private fun setLastVideoSavedPosition() {
if (mStoredLastVideoPath == medium.path && mStoredLastVideoPosition > 0) {
setPosition(mStoredLastVideoPosition)
}
}
private fun initTimeHolder() {
val left = 0
val top = 0
var right = 0
var bottom = 0
if (hasNavBar()) {
if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) {
bottom += context!!.navigationBarHeight
} else {
right += context!!.navigationBarWidth
bottom += context!!.navigationBarHeight
}
}
if (context!!.config.bottomActions) {
bottom += resources.getDimension(R.dimen.bottom_actions_height).toInt()
}
mTimeHolder.setPadding(left, top, right, bottom)
mSeekBar = mView.video_seekbar
mSeekBar!!.setOnSeekBarChangeListener(this)
mTimeHolder.beInvisibleIf(mIsFullscreen)
}
private fun hasNavBar(): Boolean {
val display = context!!.windowManager.defaultDisplay
val realDisplayMetrics = DisplayMetrics()
display.getRealMetrics(realDisplayMetrics)
val displayMetrics = DisplayMetrics()
display.getMetrics(displayMetrics)
return (realDisplayMetrics.widthPixels - displayMetrics.widthPixels > 0) || (realDisplayMetrics.heightPixels - displayMetrics.heightPixels > 0)
}
private fun setupTimeHolder() {
mSeekBar!!.max = mDuration
mView.video_duration.text = mDuration.getFormattedDuration()
setupTimer()
}
private fun setupTimer() {
activity!!.runOnUiThread(object : Runnable {
override fun run() {
if (mExoPlayer != null && !mIsDragged && mIsPlaying) {
mCurrTime = (mExoPlayer!!.currentPosition / 1000).toInt()
mSeekBar!!.progress = mCurrTime
mCurrTimeView!!.text = mCurrTime.getFormattedDuration()
}
mTimerHandler.postDelayed(this, 1000)
}
})
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt(PROGRESS, mCurrTime)
}
private fun checkFullscreen() {
if (activity == null) {
return
}
var anim = android.R.anim.fade_in
if (mIsFullscreen) {
anim = android.R.anim.fade_out
mSeekBar!!.setOnSeekBarChangeListener(null)
} else {
mSeekBar!!.setOnSeekBarChangeListener(this)
}
AnimationUtils.loadAnimation(activity, anim).apply {
duration = 150
fillAfter = true
mTimeHolder.startAnimation(this)
}
}
private fun togglePlayPause() {
if (activity == null || !isAdded)
return
mIsPlaying = !mIsPlaying
mHidePlayPauseHandler.removeCallbacksAndMessages(null)
if (mIsPlaying) {
playVideo()
} else {
pauseVideo()
}
}
fun playVideo() {
if (mExoPlayer == null) {
return
}
if (mView.video_preview.isVisible()) {
mView.video_preview.beGone()
initExoPlayer()
}
val wasEnded = videoEnded()
if (wasEnded) {
setPosition(0)
}
if (mStoredRememberLastVideoPosition) {
setLastVideoSavedPosition()
clearLastVideoSavedProgress()
}
if (!wasEnded || context?.config?.loopVideos == false) {
mView.video_play_outline.setImageResource(R.drawable.ic_pause)
mView.video_play_outline.alpha = PLAY_PAUSE_VISIBLE_ALPHA
}
schedulePlayPauseFadeOut()
mWasVideoStarted = true
mIsPlaying = true
mExoPlayer?.playWhenReady = true
activity!!.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
private fun clearLastVideoSavedProgress() {
mStoredLastVideoPosition = 0
mStoredLastVideoPath = ""
}
private fun pauseVideo() {
if (mExoPlayer == null) {
return
}
mIsPlaying = false
if (!videoEnded()) {
mExoPlayer?.playWhenReady = false
}
mView.video_play_outline?.setImageResource(R.drawable.ic_play)
mView.video_play_outline?.alpha = PLAY_PAUSE_VISIBLE_ALPHA
schedulePlayPauseFadeOut()
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
private fun schedulePlayPauseFadeOut() {
mHidePlayPauseHandler.removeCallbacksAndMessages(null)
mHidePlayPauseHandler.postDelayed({
mView.video_play_outline.animate().alpha(0f).start()
}, HIDE_PLAY_PAUSE_DELAY)
}
private fun videoEnded(): Boolean {
val currentPos = mExoPlayer?.currentPosition ?: 0
val duration = mExoPlayer?.duration ?: 0
return currentPos != 0L && currentPos >= duration
}
private fun setPosition(seconds: Int) {
mExoPlayer?.seekTo(seconds * 1000L)
mSeekBar!!.progress = seconds
mCurrTimeView!!.text = seconds.getFormattedDuration()
}
private fun setupVideoDuration() {
mDuration = medium.path.getVideoDuration()
setupTimeHolder()
setPosition(0)
}
private fun videoPrepared() {
if (mDuration == 0) {
mDuration = (mExoPlayer!!.duration / 1000).toInt()
setupTimeHolder()
setPosition(mCurrTime)
if (mIsFragmentVisible && (context!!.config.autoplayVideos)) {
playVideo()
}
}
}
private fun videoCompleted() {
if (!isAdded || mExoPlayer == null) {
return
}
mCurrTime = (mExoPlayer!!.duration / 1000).toInt()
if (listener?.videoEnded() == false && context!!.config.loopVideos) {
playVideo()
} else {
mSeekBar!!.progress = mSeekBar!!.max
mCurrTimeView!!.text = mDuration.getFormattedDuration()
pauseVideo()
}
}
private fun cleanup() {
pauseVideo()
mCurrTimeView?.text = 0.getFormattedDuration()
releaseExoPlayer()
mSeekBar?.progress = 0
mTimerHandler.removeCallbacksAndMessages(null)
mHidePlayPauseHandler.removeCallbacksAndMessages(null)
mTextureView = null
}
private fun releaseExoPlayer() {
mExoPlayer?.stop()
Thread {
mExoPlayer?.release()
mExoPlayer = null
}.start()
}
override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture?, width: Int, height: Int) {}
override fun onSurfaceTextureUpdated(surface: SurfaceTexture?) {}
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture?) = false
override fun onSurfaceTextureAvailable(surface: SurfaceTexture?, width: Int, height: Int) {
Thread {
mExoPlayer?.setVideoSurface(Surface(mTextureView!!.surfaceTexture))
}.start()
}
private fun setVideoSize() {
if (activity == null || mTextureView == null)
return
val videoProportion = mVideoSize.x.toFloat() / mVideoSize.y.toFloat()
val display = activity!!.windowManager.defaultDisplay
val screenWidth: Int
val screenHeight: Int
val realMetrics = DisplayMetrics()
display.getRealMetrics(realMetrics)
screenWidth = realMetrics.widthPixels
screenHeight = realMetrics.heightPixels
val screenProportion = screenWidth.toFloat() / screenHeight.toFloat()
mTextureView!!.layoutParams.apply {
if (videoProportion > screenProportion) {
width = screenWidth
height = (screenWidth.toFloat() / videoProportion).toInt()
} else {
width = (videoProportion * screenHeight.toFloat()).toInt()
height = screenHeight
}
mTextureView!!.layoutParams = this
}
}
private fun checkExtendedDetails() {
if (context!!.config.showExtendedDetails) {
mView.video_details.apply {
@ -624,54 +180,6 @@ class VideoFragment : ViewPagerFragment(), TextureView.SurfaceTextureListener, S
}
}
private fun skip(forward: Boolean) {
if (mExoPlayer == null || mIsPanorama) {
return
}
val curr = mExoPlayer!!.currentPosition
val twoPercents = Math.max((mExoPlayer!!.duration / 50).toInt(), MIN_SKIP_LENGTH)
val newProgress = if (forward) curr + twoPercents else curr - twoPercents
val roundProgress = Math.round(newProgress / 1000f)
val limitedProgress = Math.max(Math.min(mExoPlayer!!.duration.toInt(), roundProgress), 0)
setPosition(limitedProgress)
if (!mIsPlaying) {
togglePlayPause()
}
}
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
if (mExoPlayer != null && fromUser) {
setPosition(progress)
}
}
override fun onStartTrackingTouch(seekBar: SeekBar) {
if (mExoPlayer == null)
return
mExoPlayer!!.playWhenReady = false
mIsDragged = true
}
override fun onStopTrackingTouch(seekBar: SeekBar) {
if (mIsPanorama) {
openPanorama()
return
}
if (mExoPlayer == null)
return
if (mIsPlaying) {
mExoPlayer!!.playWhenReady = true
} else {
togglePlayPause()
}
mIsDragged = false
}
private fun checkIfPanorama() {
try {
val fis = FileInputStream(File(medium.path))
@ -751,7 +259,6 @@ class VideoFragment : ViewPagerFragment(), TextureView.SurfaceTextureListener, S
override fun fullscreenToggled(isFullscreen: Boolean) {
mIsFullscreen = isFullscreen
checkFullscreen()
mView.video_details.apply {
if (mStoredShowExtendedDetails && isVisible()) {
animate().y(getExtendedDetailsY(height))
@ -765,7 +272,6 @@ class VideoFragment : ViewPagerFragment(), TextureView.SurfaceTextureListener, S
private fun getExtendedDetailsY(height: Int): Float {
val smallMargin = resources.getDimension(R.dimen.small_margin)
val fullscreenOffset = smallMargin + if (mIsFullscreen) 0 else mTimeHolder.height
return context!!.realScreenSize.y.toFloat() - height - fullscreenOffset
return context!!.realScreenSize.y.toFloat() - height - smallMargin
}
}

View File

@ -11,12 +11,6 @@
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<TextureView
android:id="@+id/video_surface"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"/>
<com.simplemobiletools.gallery.pro.views.MediaSideScroll
android:id="@+id/video_volume_controller"
android:layout_width="@dimen/media_side_slider_width"
@ -91,5 +85,4 @@
android:visibility="gone"
tools:text="My video\nAnother line"/>
<include layout="@layout/bottom_video_time_holder"/>
</RelativeLayout>