Vertical videos in portrait & fullscreen, UI enhancements for tablets and phones, fixes

- vertical videos now work ok in portrait and fullscreen mode at the same time
- auto pause on back press is disabled for large tablets
- large dragable area for swipe to bottom in fullscreen mode in place of top controls
- appbar will be scrolled to top when entering in fullscreen mode
This commit is contained in:
Avently 2020-02-25 02:15:22 +03:00
parent a47e6dd8c5
commit 6d7e37610c
5 changed files with 112 additions and 67 deletions

View file

@ -870,7 +870,7 @@ public class VideoDetailFragment
// If we are in fullscreen mode just exit from it via first back press // If we are in fullscreen mode just exit from it via first back press
if (player != null && player.isInFullscreen()) { if (player != null && player.isInFullscreen()) {
player.onPause(); if (!PlayerHelper.isTablet(activity)) player.onPause();
restoreDefaultOrientation(); restoreDefaultOrientation();
setAutoplay(false); setAutoplay(false);
return true; return true;
@ -1699,6 +1699,7 @@ public class VideoDetailFragment
if (currentInfo != null && info.getUrl().equals(currentInfo.getUrl())) return; if (currentInfo != null && info.getUrl().equals(currentInfo.getUrl())) return;
currentInfo = info; currentInfo = info;
setInitialData(info.getServiceId(), info.getUrl(),info.getName(), playQueue);
setAutoplay(false); setAutoplay(false);
prepareAndHandleInfo(info, true); prepareAndHandleInfo(info, true);
} }
@ -1736,6 +1737,7 @@ public class VideoDetailFragment
} }
if (relatedStreamsLayout != null) relatedStreamsLayout.setVisibility(fullscreen ? View.GONE : View.VISIBLE); if (relatedStreamsLayout != null) relatedStreamsLayout.setVisibility(fullscreen ? View.GONE : View.VISIBLE);
scrollToTop();
addVideoPlayerView(); addVideoPlayerView();
} }
@ -1842,12 +1844,10 @@ public class VideoDetailFragment
if ((!player.isPlaying() && player.getPlayQueue() != playQueue) || player.getPlayQueue() == null) if ((!player.isPlaying() && player.getPlayQueue() != playQueue) || player.getPlayQueue() == null)
setAutoplay(true); setAutoplay(true);
player.checkLandscape();
boolean orientationLocked = PlayerHelper.globalScreenOrientationLocked(activity); boolean orientationLocked = PlayerHelper.globalScreenOrientationLocked(activity);
// Let's give a user time to look at video information page if video is not playing // Let's give a user time to look at video information page if video is not playing
if (player.isPlaying()) { if (orientationLocked && !player.isPlaying()) {
player.checkLandscape();
} else if (orientationLocked) {
player.checkLandscape();
player.onPlay(); player.onPlay();
player.showControlsThenHide(); player.showControlsThenHide();
} }
@ -1927,6 +1927,7 @@ public class VideoDetailFragment
case BottomSheetBehavior.STATE_COLLAPSED: case BottomSheetBehavior.STATE_COLLAPSED:
// Re-enable clicks // Re-enable clicks
setOverlayElementsClickable(true); setOverlayElementsClickable(true);
if (player != null) player.onQueueClosed();
break; break;
case BottomSheetBehavior.STATE_DRAGGING: case BottomSheetBehavior.STATE_DRAGGING:
case BottomSheetBehavior.STATE_SETTLING: case BottomSheetBehavior.STATE_SETTLING:

View file

@ -74,9 +74,11 @@ import org.schabi.newpipe.util.*;
import java.util.List; import java.util.List;
import static android.content.Context.WINDOW_SERVICE; import static android.content.Context.WINDOW_SERVICE;
import static android.content.res.Configuration.ORIENTATION_LANDSCAPE;
import static org.schabi.newpipe.player.MainPlayer.*; import static org.schabi.newpipe.player.MainPlayer.*;
import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_BACKGROUND; import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_BACKGROUND;
import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString; import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString;
import static org.schabi.newpipe.player.helper.PlayerHelper.isTablet;
import static org.schabi.newpipe.util.AnimationUtils.Type.SLIDE_AND_ALPHA; import static org.schabi.newpipe.util.AnimationUtils.Type.SLIDE_AND_ALPHA;
import static org.schabi.newpipe.util.AnimationUtils.animateRotation; import static org.schabi.newpipe.util.AnimationUtils.animateRotation;
import static org.schabi.newpipe.util.AnimationUtils.animateView; import static org.schabi.newpipe.util.AnimationUtils.animateView;
@ -137,6 +139,7 @@ public class VideoPlayerImpl extends VideoPlayer
private boolean audioOnly = false; private boolean audioOnly = false;
private boolean isFullscreen = false; private boolean isFullscreen = false;
private boolean isVerticalVideo = false;
boolean shouldUpdateOnProgress; boolean shouldUpdateOnProgress;
private MainPlayer service; private MainPlayer service;
@ -305,11 +308,13 @@ public class VideoPlayerImpl extends VideoPlayer
openInBrowser.setVisibility(View.GONE); openInBrowser.setVisibility(View.GONE);
playerCloseButton.setVisibility(View.GONE); playerCloseButton.setVisibility(View.GONE);
getTopControlsRoot().bringToFront(); getTopControlsRoot().bringToFront();
getTopControlsRoot().setClickable(false);
getTopControlsRoot().setFocusable(false);
getBottomControlsRoot().bringToFront(); getBottomControlsRoot().bringToFront();
onQueueClosed(); onQueueClosed();
} else { } else {
fullscreenButton.setVisibility(View.GONE); fullscreenButton.setVisibility(View.GONE);
setupScreenRotationButton(service.isLandscape()); setupScreenRotationButton();
getResizeView().setVisibility(View.VISIBLE); getResizeView().setVisibility(View.VISIBLE);
getRootView().findViewById(R.id.metadataView).setVisibility(View.VISIBLE); getRootView().findViewById(R.id.metadataView).setVisibility(View.VISIBLE);
moreOptionsButton.setVisibility(View.VISIBLE); moreOptionsButton.setVisibility(View.VISIBLE);
@ -323,6 +328,10 @@ public class VideoPlayerImpl extends VideoPlayer
defaultPreferences.getBoolean(service.getString(R.string.show_play_with_kodi_key), false) ? View.VISIBLE : View.GONE); defaultPreferences.getBoolean(service.getString(R.string.show_play_with_kodi_key), false) ? View.VISIBLE : View.GONE);
openInBrowser.setVisibility(View.VISIBLE); openInBrowser.setVisibility(View.VISIBLE);
playerCloseButton.setVisibility(isFullscreen ? View.GONE : View.VISIBLE); playerCloseButton.setVisibility(isFullscreen ? View.GONE : View.VISIBLE);
// Top controls have a large minHeight which is allows to drag the player down in fullscreen mode (just larger area
// to make easy to locate by finger)
getTopControlsRoot().setClickable(true);
getTopControlsRoot().setFocusable(true);
} }
if (!isInFullscreen()) { if (!isInFullscreen()) {
titleTextView.setVisibility(View.GONE); titleTextView.setVisibility(View.GONE);
@ -393,7 +402,7 @@ public class VideoPlayerImpl extends VideoPlayer
settingsContentObserver = new ContentObserver(new Handler()) { settingsContentObserver = new ContentObserver(new Handler()) {
@Override @Override
public void onChange(boolean selfChange) { setupScreenRotationButton(service.isLandscape()); } public void onChange(boolean selfChange) { setupScreenRotationButton(); }
}; };
service.getContentResolver().registerContentObserver( service.getContentResolver().registerContentObserver(
Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false, Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false,
@ -442,6 +451,14 @@ public class VideoPlayerImpl extends VideoPlayer
setPlaybackParameters(playbackTempo, playbackPitch, playbackSkipSilence); setPlaybackParameters(playbackTempo, playbackPitch, playbackSkipSilence);
} }
@Override
public void onVideoSizeChanged(int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) {
super.onVideoSizeChanged(width, height, unappliedRotationDegrees, pixelWidthHeightRatio);
isVerticalVideo = width < height;
prepareOrientation();
setupScreenRotationButton();
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// ExoPlayer Video Listener // ExoPlayer Video Listener
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@ -597,7 +614,7 @@ public class VideoPlayerImpl extends VideoPlayer
channelTextView.setVisibility(View.VISIBLE); channelTextView.setVisibility(View.VISIBLE);
playerCloseButton.setVisibility(View.GONE); playerCloseButton.setVisibility(View.GONE);
} }
setupScreenRotationButton(isInFullscreen()); setupScreenRotationButton();
} }
@Override @Override
@ -637,7 +654,8 @@ public class VideoPlayerImpl extends VideoPlayer
toggleFullscreen(); toggleFullscreen();
} else if (v.getId() == screenRotationButton.getId()) { } else if (v.getId() == screenRotationButton.getId()) {
fragmentListener.onScreenRotationButtonClicked(); if (!isVerticalVideo) fragmentListener.onScreenRotationButtonClicked();
else toggleFullscreen();
} else if (v.getId() == playerCloseButton.getId()) { } else if (v.getId() == playerCloseButton.getId()) {
service.sendBroadcast(new Intent(VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER)); service.sendBroadcast(new Intent(VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER));
@ -667,6 +685,7 @@ public class VideoPlayerImpl extends VideoPlayer
private void onQueueClicked() { private void onQueueClicked() {
queueVisible = true; queueVisible = true;
hideSystemUIIfNeeded();
buildQueue(); buildQueue();
updatePlaybackButtons(); updatePlaybackButtons();
@ -677,7 +696,9 @@ public class VideoPlayerImpl extends VideoPlayer
itemsList.scrollToPosition(playQueue.getIndex()); itemsList.scrollToPosition(playQueue.getIndex());
} }
private void onQueueClosed() { public void onQueueClosed() {
if (!queueVisible) return;
animateView(queueLayout, SLIDE_AND_ALPHA, /*visible=*/false, animateView(queueLayout, SLIDE_AND_ALPHA, /*visible=*/false,
DEFAULT_CONTROLS_DURATION, 0, () -> { DEFAULT_CONTROLS_DURATION, 0, () -> {
// Even when queueLayout is GONE it receives touch events and ruins normal behavior of the app. This line fixes it // Even when queueLayout is GONE it receives touch events and ruins normal behavior of the app. This line fixes it
@ -733,11 +754,18 @@ public class VideoPlayerImpl extends VideoPlayer
builder.create().show(); builder.create().show();
} }
private void setupScreenRotationButton(boolean landscape) { private void setupScreenRotationButton() {
boolean orientationLocked = PlayerHelper.globalScreenOrientationLocked(service); boolean orientationLocked = PlayerHelper.globalScreenOrientationLocked(service);
screenRotationButton.setVisibility(orientationLocked && videoPlayerSelected() ? View.VISIBLE : View.GONE); boolean showButton = (orientationLocked || isVerticalVideo || isTablet(service)) && videoPlayerSelected();
screenRotationButton.setVisibility(showButton ? View.VISIBLE : View.GONE);
screenRotationButton.setImageDrawable(service.getResources().getDrawable( screenRotationButton.setImageDrawable(service.getResources().getDrawable(
landscape ? R.drawable.ic_fullscreen_exit_white : R.drawable.ic_fullscreen_white)); isInFullscreen() ? R.drawable.ic_fullscreen_exit_white : R.drawable.ic_fullscreen_white));
}
private void prepareOrientation() {
boolean orientationLocked = PlayerHelper.globalScreenOrientationLocked(service);
if (orientationLocked && isInFullscreen() && service.isLandscape() == isVerticalVideo && fragmentListener != null)
fragmentListener.onScreenRotationButtonClicked();
} }
@Override @Override
@ -779,7 +807,7 @@ public class VideoPlayerImpl extends VideoPlayer
brightnessProgressBar.setMax(maxGestureLength); brightnessProgressBar.setMax(maxGestureLength);
setInitialGestureValues(); setInitialGestureValues();
queueLayout.getLayoutParams().height = min - queueLayout.getTop(); queueLayout.getLayoutParams().height = height - queueLayout.getTop();
if (popupPlayerSelected()) { if (popupPlayerSelected()) {
float widthDp = Math.abs(r - l) / service.getResources().getDisplayMetrics().density; float widthDp = Math.abs(r - l) / service.getResources().getDisplayMetrics().density;
@ -1009,7 +1037,16 @@ public class VideoPlayerImpl extends VideoPlayer
onRepeatClicked(); onRepeatClicked();
break; break;
case Intent.ACTION_CONFIGURATION_CHANGED: case Intent.ACTION_CONFIGURATION_CHANGED:
setControlsSize(); // The only situation I need to re-calculate elements sizes is when a user rotates a device from landscape to landscape
// because in that case the controls should be aligned to another side of a screen. The problem is when user leaves
// the app and returns back (while the app in landscape) Android reports via DisplayMetrics that orientation is
// portrait and it gives wrong sizes calculations. Let's skip re-calculation in every case but landscape
boolean reportedOrientationIsLandscape = service.isLandscape();
boolean actualOrientationIsLandscape = context.getResources().getConfiguration().orientation == ORIENTATION_LANDSCAPE;
if (reportedOrientationIsLandscape && actualOrientationIsLandscape) setControlsSize();
// Close it because when changing orientation from portrait (in fullscreen mode) the size of queue layout can be
// larger than the screen size
onQueueClosed();
break; break;
case Intent.ACTION_SCREEN_ON: case Intent.ACTION_SCREEN_ON:
shouldUpdateOnProgress = true; shouldUpdateOnProgress = true;
@ -1201,7 +1238,7 @@ public class VideoPlayerImpl extends VideoPlayer
// It doesn't include NavigationBar, notches, etc. // It doesn't include NavigationBar, notches, etc.
display.getSize(size); display.getSize(size);
int width = isFullscreen ? size.x : ViewGroup.LayoutParams.MATCH_PARENT; int width = isFullscreen ? (service.isLandscape() ? size.x : size.y) : ViewGroup.LayoutParams.MATCH_PARENT;
int gravity = isFullscreen ? (display.getRotation() == Surface.ROTATION_90 ? Gravity.START : Gravity.END) : Gravity.TOP; int gravity = isFullscreen ? (display.getRotation() == Surface.ROTATION_90 ? Gravity.START : Gravity.END) : Gravity.TOP;
getTopControlsRoot().getLayoutParams().width = width; getTopControlsRoot().getLayoutParams().width = width;
@ -1218,10 +1255,11 @@ public class VideoPlayerImpl extends VideoPlayer
bottomParams.addRule(gravity == Gravity.END ? RelativeLayout.ALIGN_PARENT_END : RelativeLayout.ALIGN_PARENT_START); bottomParams.addRule(gravity == Gravity.END ? RelativeLayout.ALIGN_PARENT_END : RelativeLayout.ALIGN_PARENT_START);
getBottomControlsRoot().requestLayout(); getBottomControlsRoot().requestLayout();
ViewGroup controlsRoot = getRootView().findViewById(R.id.playbackControlRoot); ViewGroup controlsRoot = getRootView().findViewById(R.id.playbackWindowRoot);
// In tablet navigationBar located at the bottom of the screen. And the only situation when we need to set custom height is // In tablet navigationBar located at the bottom of the screen. And the situations when we need to set custom height is
// in fullscreen mode in tablet in non-multiWindow mode. Other than that MATCH_PARENT is good // in fullscreen mode in tablet in non-multiWindow mode or with vertical video. Other than that MATCH_PARENT is good
controlsRoot.getLayoutParams().height = isFullscreen && !isInMultiWindow() && PlayerHelper.isTablet(service) boolean navBarAtTheBottom = PlayerHelper.isTablet(service) || !service.isLandscape();
controlsRoot.getLayoutParams().height = isFullscreen && !isInMultiWindow() && navBarAtTheBottom
? size.y ? size.y
: ViewGroup.LayoutParams.MATCH_PARENT; : ViewGroup.LayoutParams.MATCH_PARENT;
controlsRoot.requestLayout(); controlsRoot.requestLayout();
@ -1264,9 +1302,12 @@ public class VideoPlayerImpl extends VideoPlayer
public void checkLandscape() { public void checkLandscape() {
AppCompatActivity parent = getParentActivity(); AppCompatActivity parent = getParentActivity();
if (parent != null && service.isLandscape() != isInFullscreen() boolean videoInLandscapeButNotInFullscreen = service.isLandscape() && !isInFullscreen() && videoPlayerSelected() && !audioOnly;
&& getCurrentState() != STATE_COMPLETED && videoPlayerSelected() && !audioOnly) boolean playingState = getCurrentState() != STATE_COMPLETED && getCurrentState() != STATE_PAUSED;
if (parent != null && videoInLandscapeButNotInFullscreen && playingState)
toggleFullscreen(); toggleFullscreen();
setControlsSize();
} }
private void buildQueue() { private void buildQueue() {

View file

@ -160,9 +160,8 @@ public class PlayerGestureListener extends GestureDetector.SimpleOnGestureListen
isMovingInMain = true; isMovingInMain = true;
boolean acceptAnyArea = isVolumeGestureEnabled != isBrightnessGestureEnabled; boolean acceptVolumeArea = initialEvent.getX() > playerImpl.getRootView().getWidth() / 2.0;
boolean acceptVolumeArea = acceptAnyArea || initialEvent.getX() > playerImpl.getRootView().getWidth() / 2; boolean acceptBrightnessArea = initialEvent.getX() <= playerImpl.getRootView().getWidth() / 2.0;
boolean acceptBrightnessArea = acceptAnyArea || !acceptVolumeArea;
if (isVolumeGestureEnabled && acceptVolumeArea) { if (isVolumeGestureEnabled && acceptVolumeArea) {
playerImpl.getVolumeProgressBar().incrementProgressBy((int) distanceY); playerImpl.getVolumeProgressBar().incrementProgressBy((int) distanceY);

View file

@ -12,7 +12,7 @@
android:id="@+id/surfaceView" android:id="@+id/surfaceView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_centerHorizontal="true"/> android:layout_centerInParent="true"/>
<View <View
android:id="@+id/surfaceForeground" android:id="@+id/surfaceForeground"
@ -131,6 +131,12 @@
android:background="@drawable/player_top_controls_bg" android:background="@drawable/player_top_controls_bg"
android:layout_alignParentTop="true" /> android:layout_alignParentTop="true" />
<View
android:layout_width="match_parent"
android:layout_height="30dp"
android:background="@drawable/player_controls_bg"
android:layout_alignParentBottom="true" />
<!-- All top controls in this layout --> <!-- All top controls in this layout -->
<RelativeLayout <RelativeLayout
android:id="@+id/playbackWindowRoot" android:id="@+id/playbackWindowRoot"
@ -144,6 +150,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignParentTop="true" android:layout_alignParentTop="true"
android:orientation="vertical" android:orientation="vertical"
android:minHeight="80dp"
android:gravity="top" android:gravity="top"
android:paddingTop="@dimen/player_main_top_padding" android:paddingTop="@dimen/player_main_top_padding"
android:paddingStart="@dimen/player_main_controls_padding" android:paddingStart="@dimen/player_main_controls_padding"
@ -364,31 +371,26 @@
</LinearLayout> </LinearLayout>
<ImageButton <ImageButton
android:id="@+id/fullScreenButton" android:id="@+id/fullScreenButton"
android:layout_width="40dp" android:layout_width="40dp"
android:layout_height="40dp" android:layout_height="40dp"
android:padding="@dimen/player_main_buttons_padding" android:padding="@dimen/player_main_buttons_padding"
android:layout_alignParentRight="true" android:layout_alignParentRight="true"
android:background="?attr/selectableItemBackground" android:background="?attr/selectableItemBackground"
android:clickable="true" android:clickable="true"
android:focusable="true" android:focusable="true"
android:scaleType="fitCenter" android:scaleType="fitCenter"
android:src="@drawable/ic_fullscreen_white" android:src="@drawable/ic_fullscreen_white"
tools:ignore="ContentDescription,RtlHardcoded" tools:ignore="ContentDescription,RtlHardcoded"
android:visibility="gone" android:visibility="gone"
tools:visibility="visible"/> tools:visibility="visible"/>
<View
android:layout_width="match_parent"
android:layout_height="30dp"
android:background="@drawable/player_controls_bg"
android:layout_alignParentBottom="true" />
<LinearLayout <LinearLayout
android:id="@+id/bottomControls" android:id="@+id/bottomControls"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:minHeight="40dp"
android:layout_alignParentBottom="true" android:layout_alignParentBottom="true"
android:gravity="center" android:gravity="center"
android:orientation="horizontal" android:orientation="horizontal"

View file

@ -12,7 +12,7 @@
android:id="@+id/surfaceView" android:id="@+id/surfaceView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_centerHorizontal="true"/> android:layout_centerInParent="true"/>
<View <View
android:id="@+id/surfaceForeground" android:id="@+id/surfaceForeground"
@ -129,6 +129,12 @@
android:background="@drawable/player_top_controls_bg" android:background="@drawable/player_top_controls_bg"
android:layout_alignParentTop="true" /> android:layout_alignParentTop="true" />
<View
android:layout_width="match_parent"
android:layout_height="30dp"
android:background="@drawable/player_controls_bg"
android:layout_alignParentBottom="true" />
<!-- All top controls in this layout --> <!-- All top controls in this layout -->
<RelativeLayout <RelativeLayout
android:id="@+id/playbackWindowRoot" android:id="@+id/playbackWindowRoot"
@ -142,6 +148,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignParentTop="true" android:layout_alignParentTop="true"
android:orientation="vertical" android:orientation="vertical"
android:minHeight="80dp"
android:gravity="top" android:gravity="top"
android:paddingTop="@dimen/player_main_top_padding" android:paddingTop="@dimen/player_main_top_padding"
android:paddingStart="@dimen/player_main_controls_padding" android:paddingStart="@dimen/player_main_controls_padding"
@ -362,31 +369,26 @@
</LinearLayout> </LinearLayout>
<ImageButton <ImageButton
android:id="@+id/fullScreenButton" android:id="@+id/fullScreenButton"
android:layout_width="40dp" android:layout_width="40dp"
android:layout_height="40dp" android:layout_height="40dp"
android:padding="@dimen/player_main_buttons_padding" android:padding="@dimen/player_main_buttons_padding"
android:layout_alignParentRight="true" android:layout_alignParentRight="true"
android:background="?attr/selectableItemBackground" android:background="?attr/selectableItemBackground"
android:clickable="true" android:clickable="true"
android:focusable="true" android:focusable="true"
android:scaleType="fitCenter" android:scaleType="fitCenter"
android:src="@drawable/ic_fullscreen_white" android:src="@drawable/ic_fullscreen_white"
tools:ignore="ContentDescription,RtlHardcoded" tools:ignore="ContentDescription,RtlHardcoded"
android:visibility="gone" android:visibility="gone"
tools:visibility="visible"/> tools:visibility="visible"/>
<View
android:layout_width="match_parent"
android:layout_height="30dp"
android:background="@drawable/player_controls_bg"
android:layout_alignParentBottom="true" />
<LinearLayout <LinearLayout
android:id="@+id/bottomControls" android:id="@+id/bottomControls"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:minHeight="40dp"
android:layout_alignParentBottom="true" android:layout_alignParentBottom="true"
android:gravity="center" android:gravity="center"
android:orientation="horizontal" android:orientation="horizontal"