Skip to content

Commit

Permalink
Merge pull request #3477 from jwplayer/feature/fos_page_scroll_thres
Browse files Browse the repository at this point in the history
Float on scroll on mobile when player reaches floating point
  • Loading branch information
robwalch committed Sep 10, 2019
2 parents cccca42 + 3d39caa commit 5ee64d2
Show file tree
Hide file tree
Showing 5 changed files with 195 additions and 33 deletions.
12 changes: 1 addition & 11 deletions src/css/jwplayer/flags/floatingplayer.less
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

.jw-flag-touch& {
@media screen and (max-device-width : 480px) and (orientation: portrait) {
animation: jw-float-to-top 150ms cubic-bezier(0, 0.25, 0.25, 1) forwards 1;
animation: none;
top: 62px;
bottom: auto;
left: 0;
Expand Down Expand Up @@ -82,13 +82,3 @@
transform: translateY(0);
}
}

@keyframes jw-float-to-top {
from {
transform: translateY(-100%);
}

to {
transform: translateY(0);
}
}
9 changes: 9 additions & 0 deletions src/js/utils/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,12 @@ export function openLink(link, target, additionalOptions = {}) {
a.click();
}
}

export function deviceIsLandscape() {
const ort = window.screen.orientation;
const isLandscape = ort ?
ort.type === 'landscape-primary' || ort.type === 'landscape-secondary'
: false;

return isLandscape || (window.orientation === 90 || window.orientation === -90);
}
27 changes: 25 additions & 2 deletions src/js/view/utils/views-manager.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import activeTab from 'utils/active-tab';
import { Browser, OS } from 'environment/environment';
import { deviceIsLandscape } from 'utils/dom';

const views = [];
const widgets = [];
const scrollHandlers = [];
const observed = {};
const hasOrientation = 'screen' in window && 'orientation' in window.screen;
const isAndroidChrome = OS.android && Browser.chrome;

let intersectionObserver;
let scrollHandlerInitialized = false;

function lazyInitIntersectionObserver() {
const IntersectionObserver = window.IntersectionObserver;
Expand Down Expand Up @@ -44,8 +47,7 @@ function onOrientationChange() {
}

const state = model.get('state');
const orientation = window.screen.orientation.type;
const isLandscape = orientation === 'landscape-primary' || orientation === 'landscape-secondary';
const isLandscape = deviceIsLandscape();

if (!isLandscape && state === 'paused' && view.api.getFullscreen()) {
view.api.setFullscreen(false);
Expand All @@ -68,6 +70,12 @@ function removeFromGroup(view, group) {
}
}

function onScroll(e) {
scrollHandlers.forEach(handler => {
handler(e);
});
}

document.addEventListener('visibilitychange', onVisibilityChange);
document.addEventListener('webkitvisibilitychange', onVisibilityChange);

Expand All @@ -78,6 +86,7 @@ if (isAndroidChrome && hasOrientation) {
window.addEventListener('beforeunload', () => {
document.removeEventListener('visibilitychange', onVisibilityChange);
document.removeEventListener('webkitvisibilitychange', onVisibilityChange);
window.removeEventListener('scroll', onScroll);

if (isAndroidChrome && hasOrientation) {
window.screen.orientation.removeEventListener('change', onOrientationChange);
Expand All @@ -91,6 +100,20 @@ export default {
remove: function(view) {
removeFromGroup(view, views);
},
addScrollHandler: function(handler) {
if (!scrollHandlerInitialized) {
scrollHandlerInitialized = true;
window.addEventListener('scroll', onScroll);
}

scrollHandlers.push(handler);
},
removeScrollHandler: function(handler) {
let idx = scrollHandlers.indexOf(handler);
if (idx !== -1) {
scrollHandlers.splice(idx, 1);
}
},
addWidget: function(widget) {
widgets.push(widget);
},
Expand Down
133 changes: 113 additions & 20 deletions src/js/view/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import Events from 'utils/backbone.events';
import {
addClass,
deviceIsLandscape,
hasClass,
removeClass,
replaceClass,
Expand Down Expand Up @@ -96,6 +97,10 @@ function View(_api, _model) {
return { reason: 'interaction' };
}

function fosMobileBehavior() {
return OS.mobile && !deviceIsLandscape();
}

// Compute player size, handle DOM removal/insertion, add to views-manager
this.updateBounds = function () {
cancelAnimationFrame(_resizeContainerRequestId);
Expand Down Expand Up @@ -149,6 +154,11 @@ function View(_api, _model) {

_resizeMedia(containerWidth, containerHeight);
_captionsRenderer.resize();


if (_floatingConfig) {
throttledMobileFloatScrollHandler();
}
};

// Dispatch UI events for changes in player size
Expand Down Expand Up @@ -340,9 +350,53 @@ function View(_api, _model) {
playerViewModel.change('playlistItem', onPlaylistItem);
// Triggering 'resize' resulting in player 'ready'
_lastWidth = _lastHeight = null;

// Setup floating scroll handler
if (_floatingConfig && OS.mobile) {
viewsManager.addScrollHandler(throttledMobileFloatScrollHandler);
}

this.checkResized();
};

// Functions for handler float on scroll (mobile)
const FLOATING_TOP_OFFSET = 62;
let canFire = true;
let debounceTO;
function checkFloatOnScroll() {
const floating = _model.get('isFloating');
const enoughRoomForFloat = playerBounds.top < FLOATING_TOP_OFFSET;
const hasCrossedThreshold = enoughRoomForFloat ?
playerBounds.top <= window.scrollY :
playerBounds.top <= window.scrollY + FLOATING_TOP_OFFSET;

if (!floating && hasCrossedThreshold) {
_updateFloating(0, enoughRoomForFloat);
} else if (floating && !hasCrossedThreshold) {
_updateFloating(1, enoughRoomForFloat);
}
}

function throttledMobileFloatScrollHandler() {
if (!fosMobileBehavior() || !_model.get('inDom')) {
return;
}
clearTimeout(debounceTO);
debounceTO = setTimeout(checkFloatOnScroll, 150);

if (!canFire) {
return;
}

canFire = false;
checkFloatOnScroll();

setTimeout(() => {
canFire = true;
}, 50);
}
// End functions for float on scroll (mobile)

function changeControls(model, enable) {
const controlsEvent = {
controls: enable
Expand Down Expand Up @@ -880,7 +934,7 @@ function View(_api, _model) {
const intersectionRatio = Math.round(entry.intersectionRatio * 100) / 100;
_model.set('intersectionRatio', intersectionRatio);

if (_floatingConfig) {
if (_floatingConfig && !fosMobileBehavior()) {
// Only start floating if player has been mostly visible at least once.
_canFloat = _canFloat || intersectionRatio >= 0.5;
if (_canFloat) {
Expand All @@ -893,7 +947,7 @@ function View(_api, _model) {
return _model.get('isFloating') ? _wrapperElement : _playerElement;
}

function _updateFloating(intersectionRatio) {
function _updateFloating(intersectionRatio, mobileFloatIntoPlace) {
// Player is 50% visible or less and no floating player already in the DOM. Player is not in iframe
const shouldFloat = intersectionRatio < 0.5 && !isIframe();
if (shouldFloat) {
Expand All @@ -905,6 +959,22 @@ function View(_api, _model) {

addClass(_playerElement, 'jw-flag-floating');

if (mobileFloatIntoPlace) {
// Creates a dynamic animation where the top of the current player
// Smoothly transitions into the expected floating space in the event
// we can't start floating at 62px
style(_wrapperElement, {
transform: `translateY(-${FLOATING_TOP_OFFSET - playerBounds.top}px)`
});

setTimeout(() => {
style(_wrapperElement, {
transform: 'translateY(0)',
transition: 'transform 150ms cubic-bezier(0, 0.25, 0.25, 1)'
});
});
}

// Copy background from preview element, fallback to image config.
style(_playerElement, {
backgroundImage: _preview.el.style.backgroundImage || _model.get('image')
Expand All @@ -920,7 +990,7 @@ function View(_api, _model) {
_responsiveListener();
}
} else {
_this.stopFloating();
_this.stopFloating(false, mobileFloatIntoPlace);
}
}

Expand All @@ -945,30 +1015,50 @@ function View(_api, _model) {
style(_wrapperElement, styles);
}

this.stopFloating = function(forever) {
this.stopFloating = function(forever, mobileFloatIntoPlace) {
if (forever) {
_floatingConfig = null;
}
if (floatingPlayer === _playerElement) {
floatingPlayer = null;

_model.set('isFloating', false);

removeClass(_playerElement, 'jw-flag-floating');
onAspectRatioChange(_model, _model.get('aspectratio'));

// Wrapper should inherit from parent unless floating.
style(_playerElement, { backgroundImage: null }); // Reset to avoid flicker.
style(_wrapperElement, {
maxWidth: null,
width: null,
height: null,
left: null,
right: null,
top: null,
bottom: null,
margin: null
});
const resetFloatingStyles = () => {
removeClass(_playerElement, 'jw-flag-floating');
onAspectRatioChange(_model, _model.get('aspectratio'));

// Wrapper should inherit from parent unless floating.
style(_playerElement, { backgroundImage: null }); // Reset to avoid flicker.

style(_wrapperElement, {
maxWidth: null,
width: null,
height: null,
left: null,
right: null,
top: null,
bottom: null,
margin: null,
transform: null,
transition: null,
'transition-timing-function': null
});
};

if (mobileFloatIntoPlace) {
// Reverses a dynamic animation where the top of the current player
// Smoothly transitions into the expected static space in the event
// we didn't start floating at 62px
style(_wrapperElement, {
transform: `translateY(-${FLOATING_TOP_OFFSET - playerBounds.top}px)`,
'transition-timing-function': 'ease-out'
});

setTimeout(resetFloatingStyles, 150);
} else {
resetFloatingStyles();
}

_floatingUI.disable();

// Perform resize and trigger "float" event responsively to prevent layout thrashing
Expand Down Expand Up @@ -1015,6 +1105,9 @@ function View(_api, _model) {
this.resizeListener.destroy();
delete this.resizeListener;
}
if (_floatingConfig && OS.mobile) {
viewsManager.removeScrollHandler(throttledMobileFloatScrollHandler);
}
};
}

Expand Down
47 changes: 47 additions & 0 deletions test/unit/view-manager-test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ViewsManager from 'view/utils/views-manager';
import sinon from 'sinon';

describe('ViewsManager', function() {
describe('#size', () => {
Expand All @@ -14,4 +15,50 @@ describe('ViewsManager', function() {
expect(ViewsManager.size()).to.equal(0);
});
});
describe('#addScrollHandler', () => {
let mock;
it('initializes the scroll handler and calls the added handler', () => {
mock = sinon.spy();
ViewsManager.addScrollHandler(mock);

var event = document.createEvent('Event');
event.initEvent('scroll', true, true);
window.dispatchEvent(event);

expect(mock.called).to.be.true;
});
});
describe('#removeScrollHandler', () => {
let mock;
it('removes the expected handler', () => {
mock = sinon.spy();
ViewsManager.addScrollHandler(mock);

var event = document.createEvent('Event');
event.initEvent('scroll', true, true);
window.dispatchEvent(event);

expect(mock.callCount).to.eql(1);

ViewsManager.removeScrollHandler(mock);

window.dispatchEvent(event);

expect(mock.callCount).to.eql(1);
});
it('does not break if the handler doesnt exist', () => {
mock = sinon.spy();
ViewsManager.removeScrollHandler(mock);
var event = document.createEvent('Event');
event.initEvent('scroll', true, true);
window.dispatchEvent(event);
window.dispatchEvent(event);

expect(mock.callCount).to.eql(0);
});
after(() => {
// In case the test case breaks out, clean up
ViewsManager.removeScrollHandler(mock);
})
});
});

0 comments on commit 5ee64d2

Please sign in to comment.