文章

Get screen orientation from DeviceMotion

I came across a case where I have a screen locked in orientation, but still want to know the 'real' screen orientation. The example use case is a camera using HTML5 Media Stream Capture, where you keep the streaming video to the locked screen, but still want to rotate the icons on the screen when user turn their device landscape or portrait. The info is also useful for determine photo orientation.

The problem is that, when you have locked, screen.orientation will always return the same value as expected. But you really want to know the device orientation. Here you can use DeviceMotionEvent to achieve the detection but you will need to do the crazy math on the accelerometer data (x,y,z, alpha, beta, gamma....). The basic idea is to find the angle of gravity.

I've done some research and came to two implementations. Honestly I don't fully understand the math here, but the results looked acceptable. One is this Android algorithm, which would translate to the js side like this:

const getOri = e => {
    const x = -e.accelerationIncludingGravity.x;
    const y = -e.accelerationIncludingGravity.y;
    const z = -e.accelerationIncludingGravity.z;
    const magnitude = x * x + y * y;
    let newRotationDeg;
    if (magnitude * 4 > z * z) {
        const ONE_EIGHTY_OVER_PI = 57.29577957855;
        const angle = Math.atan2(-y, x) * ONE_EIGHTY_OVER_PI;
        newRotationDeg = 90 - Math.round(angle);
        // normalize to 0 - 359 range
        while (newRotationDeg >= 360) {
            newRotationDeg -= 360;
        }
        while (newRotationDeg < 0) {
            newRotationDeg += 360;
        }
    }
    let orientation;
    if (newRotationDeg >= 46 && newRotationDeg <= 135) {
        orientation = 'landscape-primary';
    } else if (newRotationDeg >= 136 && newRotationDeg <= 225) {
        orientation = 'portrait-secondary';
    } else if (newRotationDeg >= 226 && newRotationDeg <= 315) {
        orientation = 'landscape-secondary';
    } else {
        orientation = 'portrait-primary';
    }
});

The second one is directly from the Chromium source of screen_orientation_controller. It has more consideration on the edge cases. Here is what the code in JS:

const PORTRAIT_PRIMARY = 'portrait-primary';
const PORTRAIT_SECONDARY = 'portrait-secondary';
const LANDSCAPE_PRIMARY = 'landscape-primary';
const LANDSCAPE_SECONDARY = 'landscape-secondary';

const vectorLenSquared = ([x, y, z]) => x * x + y * y + z * z;
const vectorLen = v => Math.sqrt(vectorLenSquared(v));
const radToDeg = rad => (rad * 180) / Math.PI;
const dotProduct = ([x1, y1, z1], [x2, y2, z2]) => x1 * x2 + y1 * y2 + z1 * z2;
const crossProduct = ([x1, y1, z1], [x2, y2, z2]) => [
    y1 * z2 - z1 * y2,
    z1 * x2 - x1 * z2,
    x1 * y2 - y1 * x2
];
const angleBetweenVectorsInDegree = (base, other) =>
    radToDeg(
        Math.acos(dotProduct(base, other) / vectorLen(base) / vectorLen(other))
    );

const clockwiseAngleBetweenVectorsInDegree = (base, other, normal) => {
    let angle = angleBetweenVectorsInDegree(base, other);
    let cross = crossProduct(base, other);
    return dotProduct(cross, normal) > 0 ? 360 - angle : angle;
};

const getOri = (currentOri, e) => {
    const { x, y } = e.accelerationIncludingGravity;

    const lidFlatten = [x, y, 0];
    if (vectorLen(lidFlatten) < 4.2) {
        return currentOri;
    }

    const rotationReference = [-1, 1, 0];
    let down;
    if (currentOri === PORTRAIT_PRIMARY) {
        down = [0, 1, 0];
    } else if (currentOri === LANDSCAPE_PRIMARY) {
        down = [1, 0, 0];
    } else if (currentOri === PORTRAIT_SECONDARY) {
        down = [1, -1, 0];
    } else {
        down = [-1, 0, 0];
    }

    if (angleBetweenVectorsInDegree(down, lidFlatten) < 60) {
        return currentOri;
    }

    let angle = clockwiseAngleBetweenVectorsInDegree(
        rotationReference,
        lidFlatten,
        [0, 0, 1]
    );

    let newOri = LANDSCAPE_SECONDARY;
    if (angle < 90) {
        newOri = PORTRAIT_PRIMARY;
    } else if (angle < 180) {
        newOri = LANDSCAPE_PRIMARY;
    } else if (angle < 270) {
        newOri = PORTRAIT_SECONDARY;
    }
    return newOri;
};

I've put up a demo page to showcase both algorithms. Click on the fullscreen to see how the emoticon response in the screen-locked page. (Locking is allowed in fullscreen mode).

From my testing device, I found that the event listener is quite aggressive that it continues to call the handler event even if I just left the device on the table. It may due the sensitive nature of the accelerometer. So you may need to limit the calls by things like _.debounce or requestAnimationFrame.

Links:

*