Commit c4d6b6f2 authored by Kuo Jen Wei's avatar Kuo Jen Wei Committed by Commit Bot

CCA: Add video snapshot and pause button.

Make the following changes for new video snapshot and pause button:

1. Enlarge the window mode App window size to 850x478.
2. Add new 'recording' state associate with duration from start to stop
   of MediaRecorder as well as the present of two new buttons.
3. Add new 'recording-paused' state associate with the paused state of
   MediaRecorder.

Bug: 1054314
Test: tast run <DUT> "CCAUI*"

Change-Id: Ica7855e319721f61823db2b0e6208129a3c22cfc
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2231921
Commit-Queue: Kuo Jen Wei <inker@chromium.org>
Auto-Submit: Kuo Jen Wei <inker@chromium.org>
Reviewed-by: default avatarWei Lee <wtlee@chromium.org>
Cr-Commit-Position: refs/heads/master@{#777306}
parent 83b3baa6
......@@ -78,6 +78,7 @@ copy("chrome_camera_app_images") {
"src/images/camera_shutter_photo_start_hover.svg",
"src/images/camera_shutter_photo_stop.svg",
"src/images/camera_shutter_photo_stop_hover.svg",
"src/images/camera_shutter_video_pause.svg",
"src/images/settings_button_back.svg",
"src/images/settings_button_expand.svg",
"src/images/settings_feedback.svg",
......
......@@ -129,6 +129,7 @@
<include name="IDR_CAMERA_SETTINGS_FEEDBACK_SVG" file="src/images/settings_feedback.svg" type="BINDATA" />
<include name="IDR_CAMERA_CAMERA_BUTTON_SWITCH_VIDEO_SVG" file="src/images/camera_button_switch_video.svg" type="BINDATA" />
<include name="IDR_CAMERA_CAMERA_SHUTTER_PHOTO_START_HOVER_SVG" file="src/images/camera_shutter_photo_start_hover.svg" type="BINDATA" />
<include name="IDR_CAMERA_CAMERA_SHUTTER_VIDEO_PAUSE_SVG" file="src/images/camera_shutter_video_pause.svg" type="BINDATA" />
<include name="IDR_CAMERA_CAMERA_BUTTON_TIMER_ON_10S_SVG" file="src/images/camera_button_timer_on_10s.svg" type="BINDATA" />
<include name="IDR_CAMERA_CAMERA_MODE_PHOTO_SVG" file="src/images/camera_mode_photo.svg" type="BINDATA" />
<include name="IDR_CAMERA_CAMERA_FOCUS_AIM_SVG" file="src/images/camera_focus_aim.svg" type="BINDATA" />
......
......@@ -140,6 +140,12 @@ body.taking.video .left-stripe {
transform: translateX(-50%);
}
.vertical-center-stripe {
position: absolute;
top: 50%;
transform: translateY(-50%);
}
.top-stripe.right-stripe {
transform: translate(50%, -50%);
}
......@@ -161,7 +167,12 @@ body.taking.video .left-stripe {
}
#shutters-group {
bottom: calc((var(--modes-bottom) + var(--modes-height)) + 22px);
--shutter-size: 60px;
bottom: var(--shutter-vertical-center);
display: flex;
flex-direction: column;
transform: translate(50%, 50%);
}
body.review-result #shutters-group {
......@@ -169,7 +180,7 @@ body.review-result #shutters-group {
}
body.should-handle-intent-result #shutters-group {
bottom: var(--modes-bottom);
bottom: calc(var(--modes-bottom) + (var(--shutter-size) / 2));
}
body.tablet-landscape .actions-group {
......@@ -198,6 +209,11 @@ body.taking:not(.video) #shutters-group {
padding: var(--modes-gradient-padding) 8px;
}
body.taking.video #gallery-enter,
body.taking.video #modes-group {
display: none;
}
body.should-handle-intent-result #modes-group {
display: none;
}
......@@ -317,15 +333,36 @@ body.taking.timer:not(.video) #stop-takephoto {
display: inline-block;
}
#start-takephoto {
body:not(.recording) #video-snapshot,
body:not(.recording) #pause-recordvideo,
body.should-handle-intent-result #video-snapshot,
body.should-handle-intent-result #pause-recordvideo {
display: none;
}
#video-snapshot-holder {
bottom: calc(var(--shutter-vertical-center) + 100px);
transform: translate(50%, 50%);
}
#video-snapshot {
background-size: 44px 44px;
height: 44px;
width: 44px;
}
#start-takephoto,
#video-snapshot {
background-image: url(../images/camera_shutter_photo_start.svg);
}
#start-takephoto:hover {
#start-takephoto:hover,
#video-snapshot:hover {
background-image: url(../images/camera_shutter_photo_start_hover.svg);
}
#start-takephoto:active {
#start-takephoto:active,
#video-snapshot:active {
background-image: url(../images/camera_shutter_photo_start_active.svg);
}
......@@ -338,11 +375,24 @@ body.taking.timer:not(.video) #stop-takephoto {
}
#recordvideo {
--size: 60px;
}
#pause-recordvideo-holder {
bottom: calc(var(--shutter-vertical-center) - 100px);
transform: translate(50%, 50%);
}
#pause-recordvideo {
--size: 44px;
}
#recordvideo,
#pause-recordvideo {
--curve: cubic-bezier(0.4, 0, 0.2, 1);
--dot-size: 25%;
--durtaion: 180ms;
--red: #f44336;
--size: 60px;
--square-delay: 45ms;
--square-size: calc(100% / 3);
--white: #ffffff;
......@@ -351,10 +401,12 @@ body.taking.timer:not(.video) #stop-takephoto {
border-radius: 50%;
height: var(--size);
position: relative;
transition: var(--durtaion) var(--curve);
width: var(--size);
}
#recordvideo:hover {
#recordvideo:hover,
#pause-recordvideo:hover {
--red: #f44336;
--white: #e8eaed;
}
......@@ -363,25 +415,29 @@ body.taking.video #recordvideo:hover {
--red: #d93025;
}
#recordvideo .red-dot {
#recordvideo .red-dot,
#pause-recordvideo .red-dot {
background: var(--red);
border-radius: 50%;
box-sizing: border-box;
height: var(--dot-size);
left: calc(50% - var(--dot-size) / 2);
left: 50%;
position: absolute;
top: calc(50% - var(--dot-size) / 2);
top: 50%;
transform: translate(-50%, -50%);
transition: var(--durtaion) var(--curve);
width: var(--dot-size);
}
body.taking.video #recordvideo .red-dot {
height: var(--size);
left: 0;
top: 0;
width: var(--size);
}
body:not(.recording-paused).video #pause-recordvideo .red-dot {
--dot-size: 0px;
}
#recordvideo .white-square {
background: var(--white);
border-radius: 2px;
......@@ -402,6 +458,29 @@ body.taking.video #recordvideo .white-square {
width: var(--square-size);
}
#pause-recordvideo .two-bar {
--bar-size: 20px;
background-image: url(../images/camera_shutter_video_pause.svg);
height: var(--bar-size);
left: calc(50% - var(--bar-size) / 2);
position: absolute;
top: calc(50% - var(--bar-size) / 2);
width: var(--bar-size);
}
body.video.recording.recording-paused #recordvideo {
--size: 44px;
}
body.video.recording-paused #pause-recordvideo {
--size: 60px;
}
body.video.recording-paused #pause-recordvideo .two-bar {
--bar-size: 0px;
}
body:not(.video) #toggle-mic,
body:not(.multi-camera) #switch-device {
visibility: hidden;
......@@ -645,6 +724,7 @@ body.view-expert-settings #view-settings {
--modes-gradient-padding: 16px;
--modes-height: calc(var(--mode-item-height) * 3);
--small-icon: 40px;
--shutter-vertical-center: calc((var(--modes-bottom) + var(--modes-height)) + 52px);
}
body.max-wnd #view-camera {
......@@ -913,6 +993,10 @@ body:not(.grid):not(.view-grid-settings) #preview-grid-vertical::before {
pointer-events: none;
}
body.recording-paused #record-time {
background-color: black;
}
#record-time[hidden],
.menu-item[hidden] {
display: none; /* Required for flexbox hidden. */
......@@ -926,11 +1010,20 @@ body:not(.grid):not(.view-grid-settings) #preview-grid-vertical::before {
width: 6px;
}
#record-time #record-time-msg {
body.recording-paused #record-time .icon,
body:not(.recording-paused) #record-time [i18n-content=record_video_paused_msg] {
display: none;
}
#record-time #record-time-msg,
#record-time [i18n-content=record_video_paused_msg] {
color: white;
flex-shrink: 0;
font-family: 'Roboto', sans-serif;
font-size: 13px;
}
#record-time #record-time-msg {
margin-left: 8px;
}
......
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.33333 15.8332H5V4.1665H8.33333V15.8332ZM11.6667 15.8332V4.16657H15V15.8332H11.6667Z" fill="#80868B"/>
</svg>
......@@ -16,7 +16,7 @@ import {PerfEvent, PerfLogger} from './perf.js';
* Fixed minimum width of the window inner-bounds in pixels.
* @type {number}
*/
const MIN_WIDTH = 768;
const MIN_WIDTH = 850;
/**
* Initial apsect ratio of the window inner-bounds.
......
......@@ -35,6 +35,8 @@ export const State = {
PLAYING_RESULT_VIDEO: 'playing-result-video',
PREVIEW_VERTICAL_DOCK: 'preview-vertical-dock',
PRINT_PERFORMANCE_LOGS: 'print-performance-logs',
RECORDING: 'recording',
RECORDING_PAUSED: 'recording-paused',
REVIEW_PHOTO_RESULT: 'review-photo-result',
REVIEW_RESULT: 'review-result',
REVIEW_VIDEO_RESULT: 'review-video-result',
......
......@@ -32,6 +32,7 @@ import {Layout} from './camera/layout.js';
import {
Modes,
PhotoResult, // eslint-disable-line no-unused-vars
Video,
VideoResult, // eslint-disable-line no-unused-vars
} from './camera/modes.js';
import {Options} from './camera/options.js';
......@@ -137,7 +138,8 @@ export class Camera extends View {
this.modes_ = new Modes(
this.defaultMode_, photoPreferrer, videoPreferrer,
this.start.bind(this), this.doSavePhoto_.bind(this), createVideoSaver,
this.doSaveVideo_.bind(this), playShutterEffect);
this.doSaveVideo_.bind(this), playShutterEffect,
() => this.preview_.toImage());
/**
* @type {!Facing}
......@@ -212,6 +214,17 @@ export class Camera extends View {
}
});
document.querySelector('#video-snapshot').addEventListener('click', () => {
const videoMode = assertInstanceof(this.modes_.current, Video);
videoMode.takeSnapshot();
});
document.querySelector('#pause-recordvideo')
.addEventListener('click', () => {
const videoMode = assertInstanceof(this.modes_.current, Video);
videoMode.togglePaused();
});
// TODO(shik): Tune the timing for playing video shutter button
// animation. Currently the |TAKING| state is ended when the file is saved.
state.addObserver(state.State.TAKING, (taking) => {
......
......@@ -27,6 +27,8 @@ import {
ResolutionList, // eslint-disable-line no-unused-vars
} from '../../type.js';
import * as util from '../../util.js';
import {WaitableEvent} from '../../waitable_event.js';
import {RecordTime} from './recordtime.js';
/**
......@@ -84,6 +86,12 @@ export let DoSaveVideo;
*/
export let PlayShutterEffect;
/**
* Callback for getting frame image blob from current preview.
* @typedef {function(): !Promise<!Blob>}
*/
export let GetPreviewFrame;
/* eslint-disable no-unused-vars */
/**
......@@ -155,10 +163,11 @@ export class Modes {
* @param {!CreateVideoSaver} createVideoSaver
* @param {!DoSaveVideo} doSaveVideo
* @param {!PlayShutterEffect} playShutterEffect
* @param {!GetPreviewFrame} getPreviewFrame
*/
constructor(
defaultMode, photoPreferrer, videoPreferrer, doSwitchMode, doSavePhoto,
createVideoSaver, doSaveVideo, playShutterEffect) {
createVideoSaver, doSaveVideo, playShutterEffect, getPreviewFrame) {
/**
* @type {!DoSwitchMode}
* @private
......@@ -241,7 +250,8 @@ export class Modes {
[Mode.VIDEO]: {
captureFactory: () => new Video(
assertInstanceof(this.stream_, MediaStream), this.facing_,
createVideoSaver, doSaveVideo),
createVideoSaver, doSaveVideo, doSavePhoto, getPreviewFrame,
playShutterEffect),
isSupported: async () => true,
constraintsPreferrer: videoPreferrer,
getV1Constraints: getV1Constraints.bind(this, true),
......@@ -596,14 +606,19 @@ const VIDEO_MIMETYPE = browserProxy.isMp4RecordingEnabled() ?
/**
* Video mode capture controller.
*/
class Video extends ModeBase {
export class Video extends ModeBase {
/**
* @param {!MediaStream} stream
* @param {!Facing} facing
* @param {!CreateVideoSaver} createVideoSaver
* @param {!DoSaveVideo} doSaveVideo
* @param {!DoSavePhoto} doSaveSnapshot
* @param {!GetPreviewFrame} getPreviewFrame
* @param {!PlayShutterEffect} playShutterEffect
*/
constructor(stream, facing, createVideoSaver, doSaveVideo) {
constructor(
stream, facing, createVideoSaver, doSaveVideo, doSaveSnapshot,
getPreviewFrame, playShutterEffect) {
super(stream, facing, null);
/**
......@@ -618,6 +633,24 @@ class Video extends ModeBase {
*/
this.doSaveVideo_ = doSaveVideo;
/**
* @type {!DoSavePhoto}
* @private
*/
this.doSaveSnapshot_ = doSaveSnapshot;
/**
* @type {!GetPreviewFrame}
* @private
*/
this.getPreviewFrame_ = getPreviewFrame;
/**
* @type {!PlayShutterEffect}
* @private
*/
this.playShutterEffect_ = playShutterEffect;
/**
* Promise for play start sound delay.
* @type {?Promise}
......@@ -638,12 +671,79 @@ class Video extends ModeBase {
* @private
*/
this.recordTime_ = new RecordTime();
/**
* Promise for the snapshot actions during current recording session.
* @type {!Promise}
* @private
*/
this.snapshots_ = Promise.resolve();
/**
* Promise for process of toggling video pause/resume. Sets to null if CCA
* is already paused or resumed.
* @type {?Promise}
* @private
*/
this.togglePaused_ = null;
}
/**
* Takes a video snapshot during recording.
* @return {!Promise} Promise resolved when video snapshot is finished.
*/
takeSnapshot() {
const snapshot = (async () => {
const blob = await this.getPreviewFrame_();
this.playShutterEffect_();
const {width, height} = await util.blobToImage(blob);
const imageName = (new Filenamer()).newImageName();
await this.doSaveSnapshot_(
{resolution: {width, height}, blob}, imageName);
})();
this.snapshots_ = this.snapshots_.then(() => snapshot);
return this.snapshots_;
}
/**
* Toggles pause/resume state of video recording.
* @return {!Promise} Promise resolved when recording is paused/resumed.
*/
togglePaused() {
if (this.togglePaused_ !== null) {
return this.togglePaused_;
}
const waitable = new WaitableEvent();
this.togglePaused_ = waitable.wait();
assert(this.mediaRecorder_.state !== 'inactive');
const toPaused = this.mediaRecorder_.state !== 'paused';
const toggledEvent = toPaused ? 'pause' : 'resume';
const onToggled = () => {
this.mediaRecorder_.removeEventListener(toggledEvent, onToggled);
state.set(state.State.RECORDING_PAUSED, toPaused);
this.togglePaused_ = null;
waitable.signal();
};
this.mediaRecorder_.addEventListener(toggledEvent, onToggled);
if (toPaused) {
this.recordTime_.stop(true);
this.mediaRecorder_.pause();
} else {
this.recordTime_.start(true);
this.mediaRecorder_.resume();
}
return waitable.wait();
}
/**
* @override
*/
async start_() {
this.snapshots_ = Promise.resolve();
this.togglePaused_ = null;
this.startSound_ = sound.play('#sound-rec-start');
try {
await this.startSound_;
......@@ -690,6 +790,8 @@ class Video extends ModeBase {
PerfEvent.VIDEO_CAPTURE_POST_PROCESSING, false, {hasError: true});
throw e;
}
await this.snapshots_;
}
/**
......@@ -699,7 +801,9 @@ class Video extends ModeBase {
if (this.startSound_ && this.startSound_.cancel) {
this.startSound_.cancel();
}
if (this.mediaRecorder_ && this.mediaRecorder_.state === 'recording') {
if (this.mediaRecorder_ &&
(this.mediaRecorder_.state === 'recording' ||
this.mediaRecorder_.state === 'paused')) {
this.mediaRecorder_.stop();
}
}
......@@ -723,6 +827,9 @@ class Video extends ModeBase {
}
};
const onstop = (event) => {
state.set(state.State.RECORDING, false);
state.set(state.State.RECORDING_PAUSED, false);
this.mediaRecorder_.removeEventListener(
'dataavailable', ondataavailable);
this.mediaRecorder_.removeEventListener('stop', onstop);
......@@ -737,6 +844,8 @@ class Video extends ModeBase {
this.mediaRecorder_.addEventListener('dataavailable', ondataavailable);
this.mediaRecorder_.addEventListener('stop', onstop);
this.mediaRecorder_.start(100);
state.set(state.State.RECORDING, true);
state.set(state.State.RECORDING_PAUSED, false);
});
}
}
......
......@@ -195,6 +195,27 @@ export class Preview {
}
}
/**
* Creates an image blob of the current frame.
* @return {!Promise<!Blob>} Promise for the result.
*/
toImage() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = this.video_.videoWidth;
canvas.height = this.video_.videoHeight;
ctx.drawImage(this.video_, 0, 0);
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Photo blob error.'));
}
}, 'image/jpeg');
});
}
/**
* Displays preview metadata on preview screen.
* @return {!Promise} Promise for the operation.
......
......@@ -55,12 +55,15 @@ export class RecordTime {
/**
* Starts to count and show the elapsed recording time.
* @param {boolean=} resume If the time count is resumed from paused state.
*/
start() {
this.update_(0);
start(resume = false) {
if (!resume) {
this.ticks_ = 0;
}
this.update_(this.ticks_);
this.recordTime_.hidden = false;
this.ticks_ = 0;
this.tickTimeout_ = setInterval(() => {
this.ticks_++;
this.update_(this.ticks_);
......@@ -69,18 +72,21 @@ export class RecordTime {
/**
* Stops counting and showing the elapsed recording time.
* @param {boolean=} pause If the time count is paused temporarily.
* @return {number} Recorded time in 1 minute buckets.
*/
stop() {
stop(pause = false) {
speak('status_msg_recording_stopped');
if (this.tickTimeout_) {
clearInterval(this.tickTimeout_);
this.tickTimeout_ = null;
}
const mins = Math.ceil(this.ticks_ / 60);
this.ticks_ = 0;
this.recordTime_.hidden = true;
this.update_(0);
if (!pause) {
this.ticks_ = 0;
this.recordTime_.hidden = true;
this.update_(0);
}
return mins;
}
}
......@@ -361,6 +361,9 @@
<message desc="Label for the button to play video." name="IDS_PLAY_RESULT_VIDEO_BUTTON">
Play video
</message>
<message desc="Indicator message for recording paused." name="IDS_RECORD_VIDEO_PAUSED_MSG">
PAUSED
</message>
</messages>
</release>
</grit>
......@@ -70,6 +70,9 @@
</div>
<div class="centered-overlay" id="camera-mode"></div>
</div>
<div id="video-snapshot-holder" class="buttons right-stripe circle">
<button id="video-snapshot" tabindex="0"></button>
</div>
<div id="shutters-group" class="buttons right-stripe circle">
<button id="recordvideo" class="shutter" tabindex="0"
i18n-label="record_video_start_button">
......@@ -81,6 +84,12 @@
<button id="stop-takephoto" class="shutter" tabindex="0"
i18n-label="take_photo_cancel_button"></button>
</div>
<div id="pause-recordvideo-holder" class="buttons right-stripe circle">
<button id="pause-recordvideo" tabindex="0">
<div class="red-dot"></div>
<div class="two-bar"></div>
</button>
</div>
<div id="modes-group" class="buttons right-stripe hide">
<div class="mode-item">
<input type="radio" name="mode"
......@@ -145,6 +154,7 @@
</div>
<div class="top-stripe horizontal-center-stripe" id="record-time" hidden>
<div class="icon"></div>
<div i18n-content="record_video_paused_msg" id="paused-msg"></div>
<div id="record-time-msg"></div>
</div>
<div class="top-stripe right-stripe buttons">
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment