diff options
author | Daniel Narvaez <dwnarvaez@gmail.com> | 2013-02-06 13:49:14 (GMT) |
---|---|---|
committer | Daniel Narvaez <dwnarvaez@gmail.com> | 2013-02-06 13:49:14 (GMT) |
commit | 821413607a0718156f9d25d895e89b1c3d37aa8b (patch) | |
tree | 01c285af734ed5bba64b73b489e1e0226a94a262 /apps/system/camera | |
parent | c110fb485b3af0066c6df7aeac8c055e9d767efa (diff) |
Copy various bits from gaia
Diffstat (limited to 'apps/system/camera')
38 files changed, 2434 insertions, 0 deletions
diff --git a/apps/system/camera/index.html b/apps/system/camera/index.html new file mode 100644 index 0000000..c06bf41 --- /dev/null +++ b/apps/system/camera/index.html @@ -0,0 +1,66 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <meta http-equiv="pragma" content="no-cache"> + <title>Camera</title> + <link rel="resource" type="application/l10n" href="locales/locales.ini" /> + <link rel="resource" type="application/l10n" href="/shared/locales/date.ini" /> + <link type="text/css" rel="stylesheet" href="style/camera.css"/> + <link type="text/css" rel="stylesheet" href="style/filmstrip.css"/> + <link type="text/css" rel="stylesheet" href="style/VideoPlayer.css"/> + </head> + <body> + <div id="focus-ring"></div> + <video id="viewfinder" autoplay></video> + + <div id="hud"> + <a id="toggle-camera" class="hidden"></an> + <a id="toggle-flash" class="hidden"></a> + </div> + + <div id="controls"> + <a id="switch-button" name="Switch source" class="hidden" disabled="disabled"><span></span></a> + <a id="capture-button" name="Capture" disabled="disabled"><span></span></a> + <div id="misc-button"> + <a id="gallery-button" class="hidden" name="View Gallery"><span></span></a> + <a id="cancel-pick" class="hidden"><span></span></a> + <span id="video-timer">00:00</span> + </div> + </div> + + <div id="overlay" class="hidden"> + <div id="overlay-content"> + <h1 id="overlay-title"></h1> + <p id="overlay-text"><p> + </div> + </div> + + <!-- see filmstrip.js and filmstrip.css for these elements --> + <div id="filmstrip" class="hidden"> + <a id="filmstrip-gallery-button" class="hidden button"></a> + </div> + <div id="preview" class="offscreen"> + <div id="frame-container"> <!-- media frame rotates inside this --> + <div id="media-frame"></div> <!-- image or video here --> + </div> + <footer id="preview-controls"> <!-- camera, delete, share buttons --> + <a id="camera-button" class="button"></a> + <a id="delete-button" class="button"></a> + <a id="share-button" class="button"></a> + </footer> + </div> + + <script type="text/javascript" src="/shared/js/l10n.js"></script> + <script type="text/javascript" src="/shared/js/l10n_date.js"></script> + <script type="text/javascript" src="/shared/js/async_storage.js"></script> + <script type="text/javascript" src="/shared/js/blobview.js"></script> + <script type="text/javascript" src="/shared/js/media/jpeg_metadata_parser.js"></script> + <script type="text/javascript" src="/shared/js/media/get_video_rotation.js"></script> + <script type="text/javascript" src="/shared/js/media/video_player.js"></script> + <script type="text/javascript" src="/shared/js/media/media_frame.js"></script> + <script type="text/javascript" src="/shared/js/gesture_detector.js"></script> + <script type="text/javascript" src="js/camera.js"></script> + <script type="text/javascript" src="js/filmstrip.js"></script> + </body> +</html> diff --git a/apps/system/camera/js/camera.js b/apps/system/camera/js/camera.js new file mode 100644 index 0000000..25f12ae --- /dev/null +++ b/apps/system/camera/js/camera.js @@ -0,0 +1,982 @@ +'use strict'; + +// Utility functions +function padLeft(num, length) { + var r = String(num); + while (r.length < length) { + r = '0' + r; + } + return r; +} + + +// This handles the logic pertaining to the naming of files according +// to the Design rule for Camera File System +// * http://en.wikipedia.org/wiki/Design_rule_for_Camera_File_system +var DCFApi = (function() { + + var api = {}; + + var dcfConfigLoaded = false; + var deferredArgs = null; + var defaultSeq = {file: 1, dir: 100}; + + var dcfConfig = { + key: 'dcf_key', + seq: null, + postFix: 'MZLLA', + prefix: {video: 'VID_', image: 'IMG_'}, + ext: {video: '3gp', image: 'jpg'} + }; + + api.init = function() { + + asyncStorage.getItem(dcfConfig.key, function(value) { + + dcfConfigLoaded = true; + dcfConfig.seq = value ? value : defaultSeq; + + // We have a previous call to createDCFFilename that is waiting for + // a response, fire it again + if (deferredArgs) { + var args = deferredArgs; + api.createDCFFilename(args.storage, args.type, args.callback); + deferredArgs = null; + } + }); + }; + + api.createDCFFilename = function(storage, type, callback) { + + // We havent loaded the current counters from indexedDB yet, defer + // the call + if (!dcfConfigLoaded) { + deferredArgs = {storage: storage, type: type, callback: callback}; + return; + } + + var filepath = 'DCIM/' + dcfConfig.seq.dir + dcfConfig.postFix + '/'; + var filename = dcfConfig.prefix[type] + + padLeft(dcfConfig.seq.file, 4) + '.' + + dcfConfig.ext[type]; + + // A file with this name may have been written by the user or + // our indexeddb sequence tracker was cleared, check we wont overwrite + // anything + var req = storage.get(filepath + filename); + + // A file existed, we bump the directory then try to generate a + // new filename + req.onsuccess = function() { + dcfConfig.seq.file = 1; + dcfConfig.seq.dir += 1; + asyncStorage.setItem(dcfConfig.key, dcfConfig.seq, function() { + api.createDCFFilename(storage, type, callback); + }); + }; + + // No file existed, we are good to go + req.onerror = function() { + if (dcfConfig.seq.file < 9999) { + dcfConfig.seq.file += 1; + } else { + dcfConfig.seq.file = 1; + dcfConfig.seq.dir += 1; + } + asyncStorage.setItem(dcfConfig.key, dcfConfig.seq, function() { + callback(filepath, filename); + }); + }; + }; + + return api; + +})(); + +var Camera = { + _cameras: null, + _camera: 0, + _captureMode: null, + _recording: false, + + // In secure mode the user cannot browse to the gallery + _secureMode: window.parent !== window, + _currentOverlay: null, + + CAMERA: 'camera', + VIDEO: 'video', + + _videoTimer: null, + _videoStart: null, + _videoPath: null, + + _autoFocusSupported: 0, + _manuallyFocused: false, + + _timeoutId: 0, + _cameraObj: null, + + _photosTaken: [], + _cameraProfile: null, + + _resumeViewfinderTimer: null, + _waitingToGenerateThumb: false, + + _styleSheet: document.styleSheets[0], + _orientationRule: null, + _phoneOrientation: 0, + + _pictureStorage: null, + _videoStorage: null, + _storageState: null, + + STORAGE_INIT: 0, + STORAGE_AVAILABLE: 1, + STORAGE_NOCARD: 2, + STORAGE_UNMOUNTED: 3, + STORAGE_CAPACITY: 4, + + _pictureSize: null, + _previewPaused: false, + _previewActive: false, + + PREVIEW_PAUSE: 500, + FILMSTRIP_DURATION: 5000, // show filmstrip for 5s before fading + + _flashModes: [], + _currentFlashMode: 0, + + _config: { + fileFormat: 'jpeg' + }, + + get _previewConfig() { + delete this._previewConfig; + return this._previewConfig = { + width: document.body.clientHeight, + height: document.body.clientWidth + }; + }, + + _previewConfigVideo: { + profile: 'cif', + rotation: 0, + width: 352, + height: 288 + }, + + _shutterKey: 'camera.shutter.enabled', + _shutterSound: null, + _shutterSoundEnabled: true, + + PROMPT_DELAY: 2000, + + _watchId: null, + _position: null, + + _pendingPick: null, + + // The minimum available disk space to start recording a video. + RECORD_SPACE_MIN: 1024 * 1024 * 2, + + // Number of bytes left on disk to let us stop recording. + RECORD_SPACE_PADDING: 1024 * 1024 * 1, + + // Maximum image resolution for still photos taken with camera + MAX_IMAGE_RES: 1600 * 1200, // Just under 2 megapixels + + get overlayTitle() { + return document.getElementById('overlay-title'); + }, + + get overlayText() { + return document.getElementById('overlay-text'); + }, + + get overlay() { + return document.getElementById('overlay'); + }, + + get viewfinder() { + return document.getElementById('viewfinder'); + }, + + get switchButton() { + return document.getElementById('switch-button'); + }, + + get captureButton() { + return document.getElementById('capture-button'); + }, + + get galleryButton() { + return document.getElementById('gallery-button'); + }, + + get videoTimer() { + return document.getElementById('video-timer'); + }, + + get focusRing() { + return document.getElementById('focus-ring'); + }, + + get toggleButton() { + return document.getElementById('toggle-camera'); + }, + + get toggleFlashBtn() { + return document.getElementById('toggle-flash'); + }, + + // We have seperated init and delayedInit as we want to make sure + // that on first launch we dont interfere and load the camera + // previewStream as fast as possible, once the previewStream is + // active we do the rest of the initialisation. + init: function() { + this.setCaptureMode(this.CAMERA); + this.loadCameraPreview(this._camera, this.delayedInit.bind(this)); + }, + + delayedInit: function camera_delayedInit() { + // If we don't have any pending messages, show the usual UI + // Otherwise, determine which buttons to show once we get our + // activity message + if (!navigator.mozHasPendingMessage('activity')) { + this.galleryButton.classList.remove('hidden'); + this.switchButton.classList.remove('hidden'); + this.enableButtons(); + } + + // Dont let the phone go to sleep while the camera is + // active, user must manually close it + if (navigator.requestWakeLock) { + navigator.requestWakeLock('screen'); + } + + this.setToggleCameraStyle(); + + // We lock the screen orientation and deal with rotating + // the icons manually + var css = '#switch-button span, #capture-button span, ' + + '#gallery-button span { -moz-transform: rotate(0deg); }'; + var insertId = this._styleSheet.cssRules.length - 1; + this._orientationRule = this._styleSheet.insertRule(css, insertId); + window.addEventListener('deviceorientation', this.orientChange.bind(this)); + + this.toggleButton.addEventListener('click', this.toggleCamera.bind(this)); + this.toggleFlashBtn.addEventListener('click', this.toggleFlash.bind(this)); + this.viewfinder.addEventListener('click', this.toggleFilmStrip.bind(this)); + + this.switchButton + .addEventListener('click', this.toggleModePressed.bind(this)); + this.captureButton + .addEventListener('click', this.capturePressed.bind(this)); + this.galleryButton + .addEventListener('click', this.galleryBtnPressed.bind(this)); + + if (!navigator.mozCameras) { + this.captureButton.setAttribute('disabled', 'disabled'); + return; + } + + if (this._secureMode) { + this.galleryButton.setAttribute('disabled', 'disabled'); + } + + this._shutterSound = new Audio('./resources/sounds/shutter.ogg'); + this._shutterSound.mozAudioChannelType = 'publicnotification'; + + if ('mozSettings' in navigator) { + var req = navigator.mozSettings.createLock().get(this._shutterKey); + req.onsuccess = (function onsuccess() { + this._shutterSoundEnabled = req.result[this._shutterKey]; + }).bind(this); + + navigator.mozSettings.addObserver(this._shutterKey, (function(e) { + this._shutterSoundEnabled = e.settingValue; + }).bind(this)); + } + + this._storageState = this.STORAGE_INIT; + + this._pictureStorage = navigator.getDeviceStorage('pictures'); + this._videoStorage = navigator.getDeviceStorage('videos'), + + this._pictureStorage + .addEventListener('change', this.deviceStorageChangeHandler.bind(this)); + + navigator.mozSetMessageHandler('activity', function(activity) { + var name = activity.source.name; + if (name === 'pick') { + Camera.initPick(activity); + } + else { + // We got another activity. Perhaps we were launched from gallery + // So show our usual buttons + Camera.galleryButton.classList.remove('hidden'); + Camera.switchButton.classList.remove('hidden'); + } + Camera.enableButtons(); + }); + + DCFApi.init(); + }, + + enableButtons: function camera_enableButtons() { + if (!this._pendingPick) { + this.switchButton.removeAttribute('disabled'); + } + this.captureButton.removeAttribute('disabled'); + }, + + disableButtons: function camera_disableButtons() { + this.switchButton.setAttribute('disabled', 'disabled'); + this.captureButton.setAttribute('disabled', 'disabled'); + }, + + // When inside an activity the user cannot switch between + // the gallery or video recording. + initPick: function camera_initPick(activity) { + this._pendingPick = activity; + + // Hide the gallery and switch buttons, leaving only the shutter + this.galleryButton.classList.add('hidden'); + this.switchButton.classList.add('hidden'); + + // Display the cancel button and add an event listener for it + var cancelButton = document.getElementById('cancel-pick'); + cancelButton.classList.remove('hidden'); + cancelButton.onclick = this.cancelPick.bind(this); + }, + + cancelPick: function camera_cancelPick() { + if (this._pendingPick) { + this._pendingPick.postError('pick cancelled'); + } + this._pendingPick = null; + }, + + toggleModePressed: function camera_toggleCaptureMode(e) { + if (e.target.getAttribute('disabled')) { + return; + } + + var newMode = (this.captureMode === this.CAMERA) ? this.VIDEO : this.CAMERA; + this.disableButtons(); + this.setCaptureMode(newMode); + + function gotPreviewStream(stream) { + this.viewfinder.mozSrcObject = stream; + this.viewfinder.play(); + this.enableButtons(); + } + if (this.captureMode === this.CAMERA) { + this._cameraObj.getPreviewStream(this._previewConfig, + gotPreviewStream.bind(this)); + } else { + this._previewConfigVideo.rotation = this._phoneOrientation; + this._cameraObj.getPreviewStreamVideoMode(this._previewConfigVideo, + gotPreviewStream.bind(this)); + } + }, + + toggleCamera: function camera_toggleCamera() { + this._camera = 1 - this._camera; + this.loadCameraPreview(this._camera, this.enableButtons.bind(this)); + this.setToggleCameraStyle(); + }, + + setToggleCameraStyle: function camera_setToggleCameraStyle() { + var modeName = this._camera === 0 ? 'back' : 'front'; + this.toggleButton.setAttribute('data-mode', modeName); + }, + + toggleFlash: function camera_toggleFlash() { + if (this._currentFlashMode === this._flashModes.length - 1) { + this._currentFlashMode = 0; + } else { + this._currentFlashMode = this._currentFlashMode + 1; + } + this.setFlashMode(); + }, + + setFlashMode: function camera_setFlashMode() { + var flashModeName = this._flashModes[this._currentFlashMode]; + this.toggleFlashBtn.setAttribute('data-mode', flashModeName); + this._cameraObj.flashMode = flashModeName; + }, + + toggleRecording: function camera_toggleRecording() { + if (this._recording) { + this.stopRecording(); + return; + } + + this.startRecording(); + }, + + startRecording: function camera_startRecording() { + var captureButton = this.captureButton; + var switchButton = this.switchButton; + + var onerror = function() { + handleError('error-recording'); + } + var onsuccess = (function onsuccess() { + document.body.classList.add('capturing'); + captureButton.removeAttribute('disabled'); + this._recording = true; + this.startRecordingTimer(); + + // Hide the filmstrip to prevent the users from + // entering the preview mode after Camera starts recording + if (Filmstrip.isShown()) + Filmstrip.hide(); + + // User closed app while recording was trying to start + if (document.mozHidden) { + this.stopRecording(); + } + }).bind(this); + + var handleError = (function handleError(id) { + this.enableButtons(); + alert(navigator.mozL10n.get(id + '-title') + '. ' + + navigator.mozL10n.get(id + '-text')); + }).bind(this); + + this.disableButtons(); + + var startRecording = (function startRecording(freeBytes) { + if (freeBytes < this.RECORD_SPACE_MIN) { + handleError('nospace'); + return; + } + + var config = { + rotation: this._phoneOrientation, + maxFileSizeBytes: freeBytes - this.RECORD_SPACE_PADDING + }; + this._cameraObj.startRecording(config, + this._videoStorage, this._videoPath, + onsuccess, onerror); + }).bind(this); + + DCFApi.createDCFFilename(this._videoStorage, 'video', (function(path, name) { + this._videoPath = path + name; + + // The CameraControl API will not automatically create directories + // for the new file if they do not exist, so write a dummy file + // to the same directory via DeviceStorage to ensure that the directory + // exists before recording starts. + var dummyblob = new Blob([''], {type: 'video/3gpp'}); + var dummyfilename = path + '.' + name; + var req = this._videoStorage.addNamed(dummyblob, dummyfilename); + req.onerror = onerror; + req.onsuccess = (function fileCreated() { + this._videoStorage.delete(dummyfilename); // No need to wait for success + // Determine the number of bytes available on disk. + var spaceReq = this._videoStorage.freeSpace(); + spaceReq.onerror = onerror; + spaceReq.onsuccess = function() { + startRecording(spaceReq.result); + } + }).bind(this); + }).bind(this)); + }, + + startRecordingTimer: function camera_startRecordingTimer() { + this._videoStart = new Date().getTime(); + this.videoTimer.textContent = this.formatTimer(0); + this._videoTimer = + window.setInterval(this.updateVideoTimer.bind(this), 1000); + }, + + updateVideoTimer: function camera_updateVideoTimer() { + var videoLength = + Math.round((new Date().getTime() - this._videoStart) / 1000); + this.videoTimer.textContent = this.formatTimer(videoLength); + }, + + stopRecording: function camera_stopRecording() { + this._cameraObj.stopRecording(); + this._recording = false; + window.clearInterval(this._videoTimer); + this.enableButtons(); + document.body.classList.remove('capturing'); + + // XXX + // I need some way to know when the camera is done writing this file + // currently I'm sending this to the filmstrip which is trying to + // determine its rotation and fails sometimes if the file is not + // yet complete. For now, I just defer for a second, but + // there ought to be a better way. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=817367 + // Maybe I'll get a device storage callback... check this. + var videofile = this._videoPath; + setTimeout(function() { + Filmstrip.addVideo(videofile); + Filmstrip.show(Camera.FILMSTRIP_DURATION); + }, 1000); + }, + + formatTimer: function camera_formatTimer(time) { + var minutes = Math.floor(time / 60); + var seconds = Math.round(time % 60); + if (minutes < 60) { + return padLeft(minutes, 2) + ':' + padLeft(seconds, 2); + } + return ''; + }, + + capturePressed: function camera_doCapture(e) { + if (e.target.getAttribute('disabled')) { + return; + } + + if (this.captureMode === this.CAMERA) { + this.prepareTakePicture(); + } else { + this.toggleRecording(); + } + }, + + galleryBtnPressed: function camera_galleryBtnPressed() { + // Can't launch the gallery if the lockscreen is locked. + // The button shouldn't even be visible in this case, but + // let's be really sure here. + if (this._secureMode) + return; + + // Launch the gallery with an activity + var a = new MozActivity({ + name: 'browse', + data: { + type: 'photos' + } + }); + }, + + orientChange: function camera_orientChange(e) { + // Orientation is 0 starting at 'natural portrait' increasing + // going clockwise + var orientation = (e.beta > 45) ? 180 : + (e.beta < -45) ? 0 : + (e.gamma < -45) ? 90 : + (e.gamma > 45) ? 270 : 0; + + if (orientation !== this._phoneOrientation) { + var rule = this._styleSheet.cssRules[this._orientationRule]; + // PLEASE DO SOMETHING KITTENS ARE DYING + // Setting MozRotate to 90 or 270 causes element to disappear + rule.style.MozTransform = 'rotate(' + -(orientation + 1) + 'deg)'; + this._phoneOrientation = orientation; + + Filmstrip.setOrientation(orientation); + } + }, + + setCaptureMode: function camera_setCaptureMode(mode) { + if (this.captureMode) { + document.body.classList.remove(this.captureMode); + } + this.captureMode = mode; + document.body.classList.add(mode); + }, + + toggleFilmStrip: function camera_toggleFilmStrip(ev) { + // We will just ignore + // because the filmstrip shouldn't be shown + // while Camera is recording + if (this._recording) + return; + + if (Filmstrip.isShown()) + Filmstrip.hide(); + else + Filmstrip.show(); + }, + + loadCameraPreview: function camera_loadCameraPreview(camera, callback) { + + this.viewfinder.mozSrcObject = null; + this._timeoutId = 0; + + var viewfinder = this.viewfinder; + var style = viewfinder.style; + var width = document.body.clientHeight; + var height = document.body.clientWidth; + + style.top = ((width / 2) - (height / 2)) + 'px'; + style.left = -((width / 2) - (height / 2)) + 'px'; + + var transform = 'rotate(90deg)'; + var rotation; + if (camera == 1) { + /* backwards-facing camera */ + transform += ' scale(-1, 1)'; + rotation = 0; + } else { + /* forwards-facing camera */ + rotation = 0; + } + + style.MozTransform = transform; + style.width = width + 'px'; + style.height = height + 'px'; + + this._cameras = navigator.mozCameras.getListOfCameras(); + var options = {camera: this._cameras[this._camera]}; + + function gotPreviewScreen(stream) { + viewfinder.mozSrcObject = stream; + viewfinder.play(); + + if (callback) { + callback(); + } + + this._previewActive = true; + this.checkStorageSpace(); + setTimeout(this.initPositionUpdate.bind(this), this.PROMPT_DELAY); + } + + function gotCamera(camera) { + this._cameraObj = camera; + this._config.rotation = rotation; + this._autoFocusSupported = + camera.capabilities.focusModes.indexOf('auto') !== -1; + this._pictureSize = + this.pickPictureSize(camera.capabilities.pictureSizes); + this.enableCameraFeatures(camera.capabilities); + camera.onShutter = (function() { + if (this._shutterSoundEnabled) { + this._shutterSound.play(); + } + }).bind(this); + camera.onRecorderStateChange = this.recordingStateChanged.bind(this); + if (this.captureMode === this.CAMERA) { + camera.getPreviewStream(this._previewConfig, gotPreviewScreen.bind(this)); + } else { + this._previewConfigVideo.rotation = this._phoneOrientation; + this._cameraObj.getPreviewStreamVideoMode(this._previewConfigVideo, + gotPreviewScreen.bind(this)); + } + } + + // If there is already a camera, we would have to release it first. + if (this._cameraObj) { + this.release(function camera_release_callback() { + navigator.mozCameras.getCamera(options, gotCamera.bind(this)); + }); + } else { + navigator.mozCameras.getCamera(options, gotCamera.bind(this)); + } + }, + + recordingStateChanged: function(msg) { + if (msg === 'FileSizeLimitReached') { + this.stopRecording(); + alert(navigator.mozL10n.get('size-limit-reached')); + } + }, + + enableCameraFeatures: function camera_enableCameraFeatures(capabilities) { + if (this._cameras.length > 1) { + this.toggleButton.classList.remove('hidden'); + } else { + this.toggleButton.classList.add('hidden'); + } + + this._flashModes = capabilities.flashModes; + if (this._flashModes) { + this.setFlashMode(); + this.toggleFlashBtn.classList.remove('hidden'); + } else { + this.toggleFlashBtn.classList.add('hidden'); + } + }, + + startPreview: function camera_startPreview() { + this.viewfinder.play(); + this.loadCameraPreview(this._camera, this.enableButtons.bind(this)); + this._previewActive = true; + }, + + stopPreview: function camera_stopPreview() { + if (this._recording) { + this.stopRecording(); + } + this.disableButtons(); + this.viewfinder.pause(); + this._previewActive = false; + this.viewfinder.mozSrcObject = null; + this.release(); + }, + + resumePreview: function camera_resumePreview() { + this._cameraObj.resumePreview(); + this._previewActive = true; + this.enableButtons(); + }, + + restartPreview: function camera_restartPreview() { + this._resumeViewfinderTimer = + window.setTimeout(this.resumePreview.bind(this), this.PREVIEW_PAUSE); + }, + + takePictureSuccess: function camera_takePictureSuccess(blob) { + this._manuallyFocused = false; + this.hideFocusRing(); + this.restartPreview(); + DCFApi.createDCFFilename(this._pictureStorage, 'image', (function(path, name) { + var addreq = this._pictureStorage.addNamed(blob, path + name); + addreq.onsuccess = (function() { + if (this._pendingPick) { + // XXX: https://bugzilla.mozilla.org/show_bug.cgi?id=806503 + // We ought to just be able to pass this blob to the activity. + // But there seems to be a bug with blob lifetimes and activities. + // So we'll get a new blob back out of device storage to ensure + // that we've got a file-backed blob instead of a memory-backed blob. + var getreq = this._pictureStorage.get(path + name); + getreq.onsuccess = (function() { + this._pendingPick.postResult({ + type: 'image/jpeg', + blob: getreq.result + }); + this.cancelPick(); + }).bind(this); + + return; + } + + Filmstrip.addImage(path + name, blob); + Filmstrip.show(Camera.FILMSTRIP_DURATION); + this.checkStorageSpace(); + + }).bind(this); + + addreq.onerror = function() { + alert(navigator.mozL10n.get('error-saving-title') + '. ' + + navigator.mozL10n.get('error-saving-text')); + }; + }).bind(this)); + }, + + hideFocusRing: function camera_hideFocusRing() { + this.focusRing.removeAttribute('data-state'); + }, + + checkStorageSpace: function camera_checkStorageSpace() { + if (this.updateOverlay()) { + return; + } + + // The first time we're called, we need to make sure that there + // is an sdcard and that it is mounted. (Subsequently the device + // storage change handler will track that.) + if (this._storageState === this.STORAGE_INIT) { + this._pictureStorage.available().onsuccess = (function(e) { + this.updateStorageState(e.target.result); + this.updateOverlay(); + // Now call the parent method again, so that if the sdcard is + // available we will actually verify that there is enough space on it + this.checkStorageSpace(); + }.bind(this)); + return; + } + + // Now verify that there is enough space to store a picture + // 4 bytes per pixel plus some room for a header should be more + // than enough for a JPEG image. + var MAX_IMAGE_SIZE = + (this._pictureSize.width * this._pictureSize.height * 4) + 4096; + + this._pictureStorage.freeSpace().onsuccess = (function(e) { + // XXX + // If we ever enter this out-of-space condition, it looks like + // this code will never be able to exit. The user will have to + // quit the app and start it again. Just deleting files will + // not be enough to get back to the STORAGE_AVAILABLE state. + // To fix this, we need an else clause here, and also a change + // in the updateOverlay() method. + if (e.target.result < MAX_IMAGE_SIZE) { + this._storageState = this.STORAGE_CAPACITY; + } + this.updateOverlay(); + }).bind(this); + }, + + deviceStorageChangeHandler: function camera_deviceStorageChangeHandler(e) { + switch (e.reason) { + case 'available': + case 'unavailable': + case 'shared': + this.updateStorageState(e.reason); + break; + } + this.checkStorageSpace(); + }, + + updateStorageState: function camera_updateStorageState(state) { + switch (state) { + case 'available': + this._storageState = this.STORAGE_AVAILABLE; + break; + case 'unavailable': + this._storageState = this.STORAGE_NOCARD; + break; + case 'shared': + this._storageState = this.STORAGE_UNMOUNTED; + break; + } + }, + + updateOverlay: function camera_updateOverlay() { + if (this._storageState === this.STORAGE_INIT) { + return false; + } + + if (this._storageState === this.STORAGE_AVAILABLE) { + // Preview may have previously been paused if storage + // was not available + if (!this._previewActive && !document.mozHidden) { + this.startPreview(); + } + this.showOverlay(null); + return false; + } + + switch (this._storageState) { + case this.STORAGE_NOCARD: + this.showOverlay('nocard'); + break; + case this.STORAGE_UNMOUNTED: + this.showOverlay('pluggedin'); + break; + case this.STORAGE_CAPACITY: + this.showOverlay('nospace2'); + break; + } + if (this._previewActive) { + this.stopPreview(); + } + return true; + }, + + prepareTakePicture: function camera_takePicture() { + this.disableButtons(); + this.focusRing.setAttribute('data-state', 'focusing'); + if (this._autoFocusSupported && !this._manuallyFocused) { + this._cameraObj.autoFocus(this.autoFocusDone.bind(this)); + } else { + this.takePicture(); + } + }, + + autoFocusDone: function camera_autoFocusDone(success) { + if (!success) { + this.enableButtons(); + this.focusRing.setAttribute('data-state', 'fail'); + window.setTimeout(this.hideFocusRing.bind(this), 1000); + return; + } + this.focusRing.setAttribute('data-state', 'focused'); + this.takePicture(); + }, + + takePicture: function camera_takePicture() { + this._config.rotation = this._phoneOrientation; + this._config.pictureSize = this._pictureSize; + if (this._position) { + this._config.position = this._position; + } + this._cameraObj + .takePicture(this._config, this.takePictureSuccess.bind(this)); + }, + + showOverlay: function camera_showOverlay(id) { + this._currentOverlay = id; + + if (id === null) { + this.overlay.classList.add('hidden'); + return; + } + + this.overlayTitle.textContent = navigator.mozL10n.get(id + '-title'); + this.overlayText.textContent = navigator.mozL10n.get(id + '-text'); + this.overlay.classList.remove('hidden'); + }, + + pickPictureSize: function camera_pickPictureSize(pictureSizes) { + var maxRes = this.MAX_IMAGE_RES; + var size = pictureSizes.reduce(function(acc, size) { + var mp = size.width * size.height; + return (mp > acc.width * acc.height && mp <= maxRes) ? size : acc; + }, {width: 0, height: 0}); + + if (size.width === 0 && size.height === 0) { + return pictureSizes[0]; + } else { + return size; + } + }, + + initPositionUpdate: function camera_initPositionUpdate() { + if (this._watchId || document.mozHidden) { + return; + } + this._watchId = navigator.geolocation + .watchPosition(this.updatePosition.bind(this)); + }, + + updatePosition: function camera_updatePosition(position) { + this._position = { + timestamp: position.timestamp, + altitude: position.coords.altitude, + latitude: position.coords.latitude, + longitude: position.coords.longitude + }; + }, + + cancelPositionUpdate: function camera_cancelPositionUpdate() { + navigator.geolocation.clearWatch(this._watchId); + this._watchId = null; + }, + + release: function camera_release(callback) { + if (!this._cameraObj) + return; + + this._cameraObj.release(function cameraReleased() { + Camera._cameraObj = null; + if (callback) + callback.call(Camera); + }, function releaseError() { + console.warn('Camera: failed to release hardware?'); + if (callback) + callback.call(Camera); + }); + } +}; + +Camera.init(); + +document.addEventListener('mozvisibilitychange', function() { + if (document.mozHidden) { + Camera.stopPreview(); + Camera.cancelPick(); + Camera.cancelPositionUpdate(); + if (this._secureMode) // If the lockscreen is locked + Filmstrip.clear(); // then forget everything when closing camera + } else { + Camera.startPreview(); + } +}); + +window.addEventListener('beforeunload', function() { + window.clearTimeout(Camera._timeoutId); + delete Camera._timeoutId; + Camera.viewfinder.mozSrcObject = null; +}); diff --git a/apps/system/camera/js/filmstrip.js b/apps/system/camera/js/filmstrip.js new file mode 100644 index 0000000..d428801 --- /dev/null +++ b/apps/system/camera/js/filmstrip.js @@ -0,0 +1,568 @@ +/* + * filmstrip.js: filmstrip, thumbnails and previews for the camera. + */ + +'use strict'; + +var Filmstrip = (function() { + + // This array holds all the data we need for image and video previews + var items = []; + var currentItemIndex; + + // Maximum number of thumbnails in the filmstrip + var MAX_THUMBNAILS = 5; + var THUMBNAIL_WIDTH = 46; // size of each thumbnail + var THUMBNAIL_HEIGHT = 46; + + // Timer for auto-hiding the filmstrip + var hideTimer = null; + + // Document elements we care about + var filmstrip = document.getElementById('filmstrip'); + var preview = document.getElementById('preview'); + var frameContainer = document.getElementById('frame-container'); + var mediaFrame = document.getElementById('media-frame'); + var cameraButton = document.getElementById('camera-button'); + var shareButton = document.getElementById('share-button'); + var deleteButton = document.getElementById('delete-button'); + var filmstripGalleryButton = + document.getElementById('filmstrip-gallery-button'); + + // Offscreen elements for generating thumbnails with + var offscreenImage = new Image(); + var offscreenVideo = document.createElement('video'); + + // Set up event handlers + cameraButton.onclick = returnToCameraMode; + deleteButton.onclick = deleteCurrentItem; + shareButton.onclick = shareCurrentItem; + filmstripGalleryButton.onclick = Camera.galleryBtnPressed; + mediaFrame.addEventListener('dbltap', handleDoubleTap); + mediaFrame.addEventListener('transform', handleTransform); + mediaFrame.addEventListener('pan', handlePan); + mediaFrame.addEventListener('swipe', handleSwipe); + + // Generate gesture events + var gestureDetector = new GestureDetector(mediaFrame); + gestureDetector.startDetecting(); + + // Create the MediaFrame for previews + var frame = new MediaFrame(mediaFrame); + + // Start off with it positioned correctly. + setOrientation(Camera._phoneOrientation); + + // If we're running in secure mode, we never want the user to see the + // gallery button or the share button. + filmstrip.removeChild(filmstripGalleryButton); + shareButton.parentNode.removeChild(shareButton); + + function isShown() { + return !filmstrip.classList.contains('hidden'); + } + + function hide() { + filmstrip.classList.add('hidden'); + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + } + } + + /* + * With a time, show the filmstrip and then hide it after the time is up. + * Without time, show until hidden. + * Tapping in the camera toggles it. And if toggled on, it will be on + * without a timer. + * It is always on when a preview is shown. + * After recording a photo or video, it is shown for 5 seconds. + * And it is also shown for 5 seconds after leaving preview mode. + */ + function show(time) { + filmstrip.classList.remove('hidden'); + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + } + if (time) + hideTimer = setTimeout(hide, time); + } + + filmstrip.onclick = function(event) { + var target = event.target; + if (!target || !target.classList.contains('thumbnail')) + return; + + var index = parseInt(target.dataset.index); + previewItem(index); + // If we're showing previews be sure we're showing the filmstrip + // with no timeout and be sure that the viewfinder video is paused. + show(); + Camera.viewfinder.pause(); + // If there is a preview shown, we want the gallery button in + // the filmstrip + filmstripGalleryButton.classList.remove('hidden'); + }; + + function previewItem(index) { + // Don't redisplay the item if it is already displayed + if (currentItemIndex === index) + return; + + var item = items[index]; + + if (item.isImage) { + frame.displayImage(item.blob, item.width, item.height, item.preview); + } + else if (item.isVideo) { + frame.displayVideo(item.blob, item.width, item.height, item.rotation); + } + + preview.classList.remove('offscreen'); + currentItemIndex = index; + + // Highlight the border of the thumbnail we're previewing + // and clear the highlight on all others + items.forEach(function(item, itemindex) { + if (itemindex === index) + item.element.classList.add('previewed'); + else + item.element.classList.remove('previewed'); + }); + } + + function returnToCameraMode() { + Camera.viewfinder.play(); // Restart the viewfinder + show(Camera.FILMSTRIP_DURATION); // Fade the filmstrip after a delay + // hide the gallery button in the filmstrip + filmstripGalleryButton.classList.add('hidden'); + preview.classList.add('offscreen'); + frame.clear(); + if (items.length > 0) + items[currentItemIndex].element.classList.remove('previewed'); + currentItemIndex = null; + } + + function deleteCurrentItem() { + var item = items[currentItemIndex]; + var msg, storage, filename; + + if (item.isImage) { + msg = navigator.mozL10n.get('delete-photo?'); + storage = Camera._pictureStorage; + filename = item.filename; + } + else { + msg = navigator.mozL10n.get('delete-video?'); + storage = Camera._videoStorage; + filename = item.filename; + } + + // The system app is not allowed to use confirm, I think + // so if we're running in secure mode, just delete the file without + // confirmation + if (Camera._secureMode || confirm(msg)) { + // Remove the item from the array of items + items.splice(currentItemIndex, 1); + + // Remove the thumbnail image from the filmstrip + filmstrip.removeChild(item.element); + URL.revokeObjectURL(item.element.src); + + // Renumber the item elements + items.forEach(function(item, index) { + item.element.dataset.index = index; + }); + + // If there are no more items, go back to the camera + if (items.length === 0) { + returnToCameraMode(); + } + else { + // Otherwise, switch the frame to display the next item. But if + // we just deleted the last item, then we'll need to display the + // previous item. + var newindex = currentItemIndex; + if (newindex >= items.length) + newindex = items.length - 1; + currentItemIndex = null; + previewItem(newindex); + } + + // Actually delete the file + storage.delete(filename).onerror = function(e) { + console.warn('Failed to delete', filename, + 'from DeviceStorage:', e.target.error); + } + } + } + + function shareCurrentItem() { + if (Camera._secureMode) + return; + var item = items[currentItemIndex]; + var type = item.isImage ? 'image/*' : 'video/*'; + var nameonly = item.filename.substring(item.filename.lastIndexOf('/') + 1); + var activity = new MozActivity({ + name: 'share', + data: { + type: type, + number: 1, + blobs: [item.blob], + filenames: [nameonly], + filepaths: [item.filename] /* temporary hack for bluetooth app */ + } + }); + activity.onerror = function(e) { + console.warn('Share activity error:', activity.error.name); + } + } + + function handleDoubleTap(e) { + if (!items[currentItemIndex].isImage) + return; + + var scale; + if (frame.fit.scale > frame.fit.baseScale) + scale = frame.fit.baseScale / frame.fit.scale; + else + scale = 2; + + // If the phone orientation is 0 (unrotated) then the gesture detector's + // event coordinates match what's on the screen, and we use them to + // specify a point to zoom in or out on. For other orientations we could + // calculate the correct point, but instead just use the midpoint. + var x, y; + if (Camera._phoneOrientation === 0) { + x = e.detail.clientX; + y = e.detail.clientY; + } + else { + x = mediaFrame.offsetWidth / 2; + y = mediaFrame.offsetHeight / 2; + } + + frame.zoom(scale, x, y, 200); + } + + function handleTransform(e) { + if (!items[currentItemIndex].isImage) + return; + + // If the phone orientation is 0 (unrotated) then the gesture detector's + // event coordinates match what's on the screen, and we use them to + // specify a point to zoom in or out on. For other orientations we could + // calculate the correct point, but instead just use the midpoint. + var x, y; + if (Camera._phoneOrientation === 0) { + x = e.detail.midpoint.clientX; + y = e.detail.midpoint.clientY; + } + else { + x = mediaFrame.offsetWidth / 2; + y = mediaFrame.offsetHeight / 2; + } + + frame.zoom(e.detail.relative.scale, x, y); + } + + function handlePan(e) { + if (!items[currentItemIndex].isImage) + return; + + // The gesture detector event does not take our CSS rotation into + // account, so we have to pan by a dx and dy that depend on how + // the MediaFrame is rotated + var dx, dy; + switch (Camera._phoneOrientation) { + case 0: + dx = e.detail.relative.dx; + dy = e.detail.relative.dy; + break; + case 90: + dx = -e.detail.relative.dy; + dy = e.detail.relative.dx; + break; + case 180: + dx = -e.detail.relative.dx; + dy = -e.detail.relative.dy; + break; + case 270: + dx = e.detail.relative.dy; + dy = -e.detail.relative.dx; + break; + } + + frame.pan(dx, dy); + } + + function handleSwipe(e) { + // Because the stuff around the media frame does not change position + // when the phone is rotated, we don't alter these directions based + // on orientation. To dismiss the preview, the user always swipes toward + // the filmstrip. + + switch (e.detail.direction) { + case 'up': // close the preview if the swipe is fast enough + if (e.detail.vy < -1) + returnToCameraMode(); + break; + case 'left': // go to next image if fast enough + if (e.detail.vx < -1 && currentItemIndex < items.length - 1) + previewItem(currentItemIndex + 1); + break; + case 'right': // go to previous image if fast enough + if (e.detail.vx > 1 && currentItemIndex > 0) + previewItem(currentItemIndex - 1); + break; + } + } + + function addImage(filename, blob) { + parseJPEGMetadata(blob, function getPreviewBlob(metadata) { + if (metadata.preview) { + var previewBlob = blob.slice(metadata.preview.start, + metadata.preview.end, + 'image/jpeg'); + + offscreenImage.src = URL.createObjectURL(previewBlob); + offscreenImage.onload = function() { + createThumbnailFromElement(offscreenImage, false, 0, + function(thumbnail) { + addItem({ + isImage: true, + filename: filename, + thumbnail: thumbnail, + blob: blob, + width: metadata.width, + height: metadata.height, + preview: metadata.preview + }); + }); + URL.revokeObjectURL(offscreenImage.src); + offscreenImage.onload = null; + offscreenImage.src = null; + }; + } + }, function logerr(msg) { console.warn(msg); }); + } + + function addVideo(filename) { + var request = Camera._videoStorage.get(filename); + request.onerror = function() { + console.warn('addVideo:', filename, request.error.name); + }; + request.onsuccess = function() { + var blob = request.result; + getVideoRotation(blob, function(rotation) { + if (typeof rotation !== 'number') { + console.warn('Unexpected rotation:', rotation); + rotation = 0; + } + + var url = URL.createObjectURL(blob); + + offscreenVideo.preload = 'metadata'; + offscreenVideo.style.width = THUMBNAIL_WIDTH + 'px'; + offscreenVideo.style.height = THUMBNAIL_HEIGHT + 'px'; + offscreenVideo.src = url; + + offscreenVideo.onerror = function() { + URL.revokeObjectURL(url); + offscreenVideo.onerror = null; + offscreenVideo.onloadedmetadata = null; + offscreenVideo.removeAttribute('src'); + offscreenVideo.load(); + console.warn('not a video file', filename); + } + + offscreenVideo.onloadedmetadata = function() { + createThumbnailFromElement(offscreenVideo, true, rotation, + function(thumbnail) { + addItem({ + isVideo: true, + filename: filename, + thumbnail: thumbnail, + blob: blob, + width: offscreenVideo.videoWidth, + height: offscreenVideo.videoHeight, + rotation: rotation + }); + }); + URL.revokeObjectURL(url); + offscreenVideo.onerror = null; + offscreenVideo.onloadedmetadata = null; + offscreenVideo.removeAttribute('src'); + offscreenVideo.load(); + }; + }); + }; + } + + // Add a thumbnail to the filmstrip. + // The details object contains everything we need to know + // to display the thumbnail and preview the image or video + function addItem(item) { + // Thumbnails go from most recent to least recent. + items.unshift(item); + + // Create an image element for this new thumbnail and display it + item.element = new Image(); + item.element.src = URL.createObjectURL(item.thumbnail); + item.element.classList.add('thumbnail'); + filmstrip.insertBefore(item.element, filmstrip.firstElementChild); + + // If we have too many thumbnails now, remove the oldest one from + // the array, and remove its element from the filmstrip and release + // its blob url + if (items.length > MAX_THUMBNAILS) { + var oldest = items.pop(); + filmstrip.removeChild(oldest.element); + URL.revokeObjectURL(oldest.element.src); + } + + // Now update the index associated with each of the remaining elements + // so that the click event handle knows which one it clicked on + items.forEach(function(item, index) { + item.element.dataset.index = index; + }); + } + + // Remove all items from the filmstrip. Don't delete the files, but + // forget all of our state. This also exits preview mode if we're in it. + function clear() { + if (!preview.classList.contains('offscreen')) + returnToCameraMode(); + items.forEach(function(item) { + filmstrip.removeChild(item.element); + URL.revokeObjectURL(item.element.src); + }); + items.length = 0; + } + + // Create a thumbnail size canvas, copy the <img> or <video> into it + // cropping the edges as needed to make it fit, and then extract the + // thumbnail image as a blob and pass it to the callback. + function createThumbnailFromElement(elt, video, rotation, callback) { + // Create a thumbnail image + var canvas = document.createElement('canvas'); + var context = canvas.getContext('2d'); + canvas.width = THUMBNAIL_WIDTH; + canvas.height = THUMBNAIL_HEIGHT; + var eltwidth = video ? elt.videoWidth : elt.width; + var eltheight = video ? elt.videoHeight : elt.height; + var scalex = canvas.width / eltwidth; + var scaley = canvas.height / eltheight; + + // Take the larger of the two scales: we crop the image to the thumbnail + var scale = Math.max(scalex, scaley); + + // Calculate the region of the image that will be copied to the + // canvas to create the thumbnail + var w = Math.round(THUMBNAIL_WIDTH / scale); + var h = Math.round(THUMBNAIL_HEIGHT / scale); + var x = Math.round((eltwidth - w) / 2); + var y = Math.round((eltheight - h) / 2); + + // If a rotation is specified, rotate the canvas context + if (rotation) { + context.save(); + switch (rotation) { + case 90: + context.translate(THUMBNAIL_WIDTH, 0); + context.rotate(Math.PI / 2); + break; + case 180: + context.translate(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); + context.rotate(Math.PI); + break; + case 270: + context.translate(0, THUMBNAIL_HEIGHT); + context.rotate(-Math.PI / 2); + break; + } + } + + // Draw that region of the image into the canvas, scaling it down + context.drawImage(elt, x, y, w, h, + 0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); + + // Restore the default rotation so the play arrow comes out correctly + if (rotation) { + context.restore(); + } + + // If this is a video, superimpose a translucent play button over + // the captured video frame to distinguish it from a still photo thumbnail + if (video) { + // First draw a transparent gray circle + context.fillStyle = 'rgba(0, 0, 0, .3)'; + context.beginPath(); + context.arc(THUMBNAIL_WIDTH / 2, THUMBNAIL_HEIGHT / 2, + THUMBNAIL_HEIGHT / 3, 0, 2 * Math.PI, false); + context.fill(); + + // Now outline the circle in white + context.strokeStyle = 'rgba(255,255,255,.6)'; + context.lineWidth = 2; + context.stroke(); + + // And add a white play arrow. + context.beginPath(); + context.fillStyle = 'rgba(255,255,255,.6)'; + // The height of an equilateral triangle is sqrt(3)/2 times the side + var side = THUMBNAIL_HEIGHT / 3; + var triangle_height = side * Math.sqrt(3) / 2; + context.moveTo(THUMBNAIL_WIDTH / 2 + triangle_height * 2 / 3, + THUMBNAIL_HEIGHT / 2); + context.lineTo(THUMBNAIL_WIDTH / 2 - triangle_height / 3, + THUMBNAIL_HEIGHT / 2 - side / 2); + context.lineTo(THUMBNAIL_WIDTH / 2 - triangle_height / 3, + THUMBNAIL_HEIGHT / 2 + side / 2); + context.closePath(); + context.fill(); + } + + canvas.toBlob(callback, 'image/jpeg'); + } + + function setOrientation(orientation) { + preview.dataset.orientation = orientation; + filmstrip.dataset.orientation = orientation; + mediaFrame.dataset.orientation = orientation; + + // When we rotate the media frame, we also have to change its size + var containerWidth = frameContainer.offsetWidth; + var containerHeight = frameContainer.offsetHeight; + if (orientation === 0 || orientation === 180) { + mediaFrame.style.width = containerWidth + 'px'; + mediaFrame.style.height = containerHeight + 'px'; + mediaFrame.style.top = 0 + 'px'; + mediaFrame.style.left = 0 + 'px'; + } + else { + mediaFrame.style.width = containerHeight + 'px'; + mediaFrame.style.height = containerWidth + 'px'; + mediaFrame.style.top = ((containerHeight - containerWidth) / 2) + 'px'; + mediaFrame.style.left = ((containerWidth - containerHeight) / 2) + 'px'; + } + + // And rotate so this new size fills the screen + mediaFrame.style.transform = 'rotate(-' + orientation + 'deg)'; + + // And we have to resize the frame (and its video player) + frame.resize(); + frame.video.setPlayerSize(); + } + + return { + isShown: isShown, + hide: hide, + show: show, + addImage: addImage, + addVideo: addVideo, + clear: clear, + setOrientation: setOrientation + }; +}()); diff --git a/apps/system/camera/locales/camera.ar.properties b/apps/system/camera/locales/camera.ar.properties new file mode 100644 index 0000000..9ca7690 --- /dev/null +++ b/apps/system/camera/locales/camera.ar.properties @@ -0,0 +1,19 @@ +nospace2-title = لا توجد مساحة كافية على بطاقة الذاكرة +nospace2-text = حرِّر مساحة بحذف ملفات. + +error-saving-title = لم يتم حفظ الصورة +error-saving-text = حدث خطأ منع الكاميرا من حفظ الصورة. + +error-recording-title = لم يتم تصوير الفيديو +error-recording-text = هنالك خطأ منع الكاميرا من تصوير الفيديو + +nocard-title = لم يتم العثور على بطاقة الذاكرة +nocard-text = أدخل بطاقة الذاكرة لأخذ صور. + +pluggedin-title = لا يمكن استخدام الكاميرا والجوَّال موصول. +pluggedin-text = إفصل الجوَّال لعرض الصور. + +size-limit-reached = لا توجد مساحة كافية على بطاقة الذاكرة + +delete-photo? = حذف الصورة ؟ +delete-video? = حذف الفيديو؟ diff --git a/apps/system/camera/locales/camera.en-US.properties b/apps/system/camera/locales/camera.en-US.properties new file mode 100644 index 0000000..90b318d --- /dev/null +++ b/apps/system/camera/locales/camera.en-US.properties @@ -0,0 +1,19 @@ +nospace2-title = Not enough space on memory card +nospace2-text = Try freeing up space by deleting media. + +error-saving-title = Picture not saved +error-saving-text = An error prevented Camera from saving the picture. + +error-recording-title = Video not recorded +error-recording-text = An error prevented Camera from recording the video. + +nocard-title = No memory card found +nocard-text = Insert a memory card to take pictures. + +pluggedin-title = Camera can not be used while plugged in +pluggedin-text = Unplug the phone to view pictures. + +size-limit-reached = You have run out of space on your SD card. + +delete-photo? = Delete photo? +delete-video? = Delete video? diff --git a/apps/system/camera/locales/camera.fr.properties b/apps/system/camera/locales/camera.fr.properties new file mode 100644 index 0000000..56d7d3b --- /dev/null +++ b/apps/system/camera/locales/camera.fr.properties @@ -0,0 +1,19 @@ +nospace2-title = Espace libre sur la carte mémoire insuffisant +nospace2-text = Essayez de libérer de l’espace en supprimant des médias. + +error-saving-title = Photo non sauvegardée +error-saving-text = Une erreur a empêché l’application Photo de sauvegarder la photo. + +error-recording-title = Vidéo non enregistrée +error-recording-text = Une erreur a empêché l’application Photo d’enregistrer la vidéo. + +nocard-title = Aucune carte mémoire trouvée +nocard-text = Pour prendre des photos, insérez une carte mémoire. + +pluggedin-title = L’application Photo ne peut pas être utilisée tant que le téléphone est branché +pluggedin-text = Pour afficher les photos, débranchez le téléphone. + +size-limit-reached = Vous n’avez pas assez d’espace sur votre carte SD. + +delete-photo? = Supprimer la photo ? +delete-video? = Supprimer la vidéo ? diff --git a/apps/system/camera/locales/camera.zh-TW.properties b/apps/system/camera/locales/camera.zh-TW.properties new file mode 100644 index 0000000..9cf042a --- /dev/null +++ b/apps/system/camera/locales/camera.zh-TW.properties @@ -0,0 +1,20 @@ +nospace2-title = 記憶卡的空間不足 +nospace2-text = 刪除媒體檔案以釋放可用空間。 + +error-saving-title = 相片未儲存 +error-saving-text = 發生錯誤,相機無法儲存相片。 + +error-recording-title = 影片未紀錄 +error-recording-text = 有錯誤發生使照相機無法錄製影片。 + +nocard-title = 找不到記憶卡 +nocard-text = 插入記憶卡以拍照。 + +pluggedin-title = 連接到電腦時無法使用相機 +pluggedin-text = 拔線以檢視照片。 + +size-limit-reached = 您已用盡 SD 卡上的空間。 + +delete-photo? = 刪除相片? +delete-video? = 刪除影片? + diff --git a/apps/system/camera/locales/locales.ini b/apps/system/camera/locales/locales.ini new file mode 100644 index 0000000..93f5cbc --- /dev/null +++ b/apps/system/camera/locales/locales.ini @@ -0,0 +1,11 @@ +@import url(camera.en-US.properties) + +[ar] +@import url(camera.ar.properties) + +[fr] +@import url(camera.fr.properties) + +[zh-TW] +@import url(camera.zh-TW.properties) + diff --git a/apps/system/camera/resources/sounds/shutter.ogg b/apps/system/camera/resources/sounds/shutter.ogg Binary files differnew file mode 100644 index 0000000..f0c67d6 --- /dev/null +++ b/apps/system/camera/resources/sounds/shutter.ogg diff --git a/apps/system/camera/style/VideoPlayer.css b/apps/system/camera/style/VideoPlayer.css new file mode 100644 index 0000000..bc2b23c --- /dev/null +++ b/apps/system/camera/style/VideoPlayer.css @@ -0,0 +1,152 @@ +/* styles for the video element itself */ +.videoPlayer { + position: absolute; + left: 0; /* we position it with a transform */ + top:0; + transform-origin: 0 0; +} + +/* video player controls */ +.videoPlayerControls { + position: absolute; + left: 0px; + right: 0px; + top: 0px; + bottom: 0px; + margin: 0; + padding: 0; +} + +.videoPlayerPlayButton { + position: absolute; + width: 106px; + height: 106px; + left: calc(50% - 53px); + top: calc(50% - 53px); + background: url("images/video_play_button.png") center no-repeat, + url("images/video_play_normal.png") center no-repeat; + border-width: 0; +} + +.videoPlayerPlayButton:active { + background: url("images/video_play_button.png") center no-repeat, + url("images/video_play_focus.png") center no-repeat +} + +.videoPlayerPlayButton.hidden { + opacity: 0; +} + +.videoPlayerFooter { + position: absolute; + left: 0px; + right: 0px; + bottom: 0px; + height: 50px; + margin: 0; + padding: 0; + background-color: rgba(0, 0, 0, 0.3); + overflow: hidden; + opacity: 1; + transition: opacity 0.5s; + font-family: "MozTT", sans-serif; + -moz-user-select: none; +} + +.videoPlayerFooter.hidden { + opacity: 0; + pointer-events: none; +} + +.videoPlayerPauseButton { + position: absolute; + width: 100px; + height: 100px; + padding: 0; + margin: 0; + background: url("images/video_pause_button.png") center no-repeat, + rgba(0,0,0,.5); + border-radius: 53px; + border: solid #ccc 3px; + top: -25px; + left: 10px; +} + +.videoPlayerPauseButton:active { + background: url("images/video_pause_button.png") center no-repeat, + url("images/video_play_focus.png") center no-repeat +} + +button::-moz-focus-inner { + padding: 0; + border: none; +} + +/* time slider */ +.videoPlayerSlider { + position: absolute; + left: 110px; + top: 0px; + right: 0px; + height: 100%; +} + +.videoPlayerSlider > span { + display: block; + width: 45px; + position: absolute; + color: white; + height: 100%; + line-height: 50px; + text-align: center; + font-size: 15px; +} + +.videoPlayerElapsedText { + left: 10px; +} + +.videoPlayerDurationText { + right: 10px; +} + +.videoPlayerProgress { + position: absolute; + top: 0px; + left: 70px; + right: 70px; + height: 100%; +} + +.videoPlayerProgress > div { + position: absolute; + pointer-events: none; +} + +.videoPlayerElapsedBar, .videoPlayerBackgroundBar { + height: 4px; + width: 0%; + top: 50%; + margin-top: -2px; + border-radius: 6px; +} + +.videoPlayerElapsedBar { + background-color: #0ac; +} + +.videoPlayerBackgroundBar { + background-color: #333; + width: 100%; +} + +.videoPlayerPlayHead { + display: block; + height: 20px; + width: 25px; + border-radius: 25px; + background-color: white; + top: 50%; + margin: -10px 0 0 -12px; +} + diff --git a/apps/system/camera/style/camera.css b/apps/system/camera/style/camera.css new file mode 100644 index 0000000..6a1ca02 --- /dev/null +++ b/apps/system/camera/style/camera.css @@ -0,0 +1,314 @@ +html, body { + font-family: "MozTT", sans-serif; + font-size: 10px; + height: 100%; + width: 100%; + padding: 0; + margin: 0; + overflow: hidden; + background-color: black; +} + +#viewfinder { + position: absolute; + z-index: 25; +} + +#controls { + position: absolute; + bottom: 0; + right: 0; + left: 0; + height: 45px; + z-index: 50; + background-color: rgba(0, 0, 0, 0.8); + overflow: hidden; +} + +#switch-button, #capture-button, #misc-button { + position: absolute; +} + +#switch-button, #misc-button { + height: 45px; + width: 33%; +} + +#switch-button span, +#capture-button span, +#gallery-button span, +#cancel-pick span +{ + -moz-transition: 0.2s ease-in-out; + pointer-events: none; + background-position: center center; + background-repeat: no-repeat; + display: block; + position: absolute; + top: 50%; + left: 50%; + margin-left: -15px; + margin-top: -15px; + width: 30px; + height: 30px; +} + +#switch-button { + left: 66%; +} + +#misc-button { + text-align: center; + left: 0; +} + +#video-timer { + position:relative; + top:50%; + margin-top:-0.5em; +} + +#gallery-button { + display: block; + width: 100%; + height: 100%; +} + +#gallery-button.hidden { + display:none; +} + +#gallery-button span { + background-image: url(images/grid.png); +} + +#gallery-button[disabled=disabled] { + display: none; +} + +#cancel-pick { + display:block; + width: 100%; + height: 100%; +} + +#cancel-pick.hidden { + display:none +} + +#cancel-pick span { + background-image: url(images/actionicon_cancel.png); +} + +#capture-button[disabled=disabled] { + opacity: 0.5; +} + +#switch-button[disabled=disabled] { + opacity: 0.5; +} + +#capture-button { + background-color: #03a2b4; + border-radius: 100px; + left: 33%; + height: 100px; + width: 33%; + top: -28px; +} + +#video-timer { + display: none; + color: white; +} + +/* Specific to when we are capturing video */ +.capturing #video-timer { + display: block; +} + +.capturing #gallery-button { + display: none; +} + +.capturing #capture-button { + background-color: #d3361c; +} + +.video.capturing #capture-button span { + background-image: url(images/stop.png); +} + +/* Swap the camera and video icons depending on mode */ +.video #switch-button span { + background-image: url(images/camera.png); +} + +.camera #switch-button span { + background-image: url(images/video.png); +} + +.camera #capture-button span { + background-image: url(images/camera.png); +} + +.video #capture-button span { + background-image: url(images/video.png); +} + +#focus-ring { + position: absolute; + z-index: 100; + display: none; + width: 50px; + height: 50px; + border-radius: 50px; + top: 50%; + left: 50%; + margin-top: -30px; + margin-left: -30px; +} + +#focus-ring[data-state=focused] { + border: 4px solid rgba(0, 255, 0, 0.3); + display: block; +} + +#focus-ring[data-state=focusing] { + border: 4px solid rgba(0, 0, 0, 0.8); + display: block; +} + +#focus-ring[data-state=fail] { + border: 4px solid rgba(255, 0, 0, 0.3); + display: block; +} + +/* + * The overlay is where we display messages like Scanning, No Videos, + * No SD card and SD Card in Use along with instructions for resolving + * the issue. The user can't interact with the app while the overlay + * is displayed. + */ +#overlay { + /* it takes up the whole screen */ + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + + /* almost transparent gray */ + background-color: rgba(0, 0, 0, 0.4); + z-index: 100; +} + +/* + * The overlay content area holds the text of the overlay. + * It has borders and a less transparent background so that + * the overlay text shows up more clearly + */ +#overlay-content { + background: + url(images/ui/pattern.png) repeat left top, + url(images/ui/gradient.png) no-repeat left top; + background-size: auto auto, 100% 100%; + /* We can't use shortand with background size because is not implemented yet: + https://bugzilla.mozilla.org/show_bug.cgi?id=570326; */ + overflow: hidden; + position: absolute; + z-index: 100; + top: 0; + left: 0; + right: 0; + bottom: 0; + font-family: "MozTT", Sans-serif; + font-size: 0; + /* Using font-size: 0; we avoid the unwanted visual space (about 3px) + created by white-spaces and break lines in the code betewen inline-block elements */ + color: #fff; + padding: 110px 25px 0px 25px; +} + +#overlay-title { + font-weight: normal; + font-size: 1.9rem; + color: #fff; + margin: 0 5px -10px 5px; +} + +#overlay-text { + padding: 10px 5px 0 5px; + border-top: 1px solid #686868; + font-weight: 300; + font-size: 2.5rem; + color: #ebebeb; +} + +.hidden { + display: none; +} + +#hud { + position: absolute; + top: 20px; + height: 75px; + left: 0; + right: 0; + z-index: 50; +} + +#hud a { + position: absolute; + z-index: 50; + height: 75px; + width: 75px; + border: 0; + background-position: center center; + background-repeat: no-repeat; + background-image: url(images/hud_button_underlay.png); +} + +#hud a:after { + content: " "; + display: block; + position: relative; + z-index: 60; + height: 75px; + width: 75px; + background: transparent; + background-position: center center; + background-repeat: no-repeat; +} + +#hud a:active { + background-image: url(images/hud_button_underlay_focus.png); +} + +#toggle-camera { + right: 20px; +} + +#toggle-flash { + left: 20px; +} + +#toggle-camera[data-mode=back]:after { + background-image: url(images/toggle_front.png); +} +#toggle-camera[data-mode=front]:after { + background-image: url(images/toggle_back.png); +} + +#toggle-flash[data-mode=on]:after { + background-image: url(images/flash_on.png); +} +#toggle-flash[data-mode=off]:after { + background-image: url(images/flash_off.png); +} +#toggle-flash[data-mode=auto]:after { + background-image: url(images/flash_auto.png); +} +#toggle-flash[data-mode=torch]:after { + background-image: url(images/flash_torch.png); +} diff --git a/apps/system/camera/style/filmstrip.css b/apps/system/camera/style/filmstrip.css new file mode 100644 index 0000000..fd32dc6 --- /dev/null +++ b/apps/system/camera/style/filmstrip.css @@ -0,0 +1,187 @@ +#filmstrip { + transition: 0.2s ease-in-out; + position: absolute; + z-index: 100; + left: 0; + right: 0; + height: 50px; + /* + * the background must be solid for preview mode because otherwise some + * of the frozen viewfinder shows through. If it really need to be translucent + * in camera mode, we'll have to change it with javascript + */ + background: black; +} + +#filmstrip.hidden { + transform: translateY(-50px); +} + +img.thumbnail { + position: relative; + width: 46px; + height: 46px; + border: 2px solid white; + margin-right: 4px; + float: left; /* XXX: do we need this? */ + -moz-user-select: none; + transition: 0.2s ease-in-out; +} + +img.thumbnail.previewed { /* if the thumbnail is being previewed */ + border: 2px solid #0ac; +} + +/* + * Make the thumbnails rotate with the phone + */ +#filmstrip[data-orientation="90"] img.thumbnail { + transform: rotate(-90deg); +} +#filmstrip[data-orientation="180"] img.thumbnail { + transform: rotate(-180deg); +} +#filmstrip[data-orientation="270"] img.thumbnail { + transform: rotate(-270deg); +} + +/* this is where we display image and video previews */ +#preview { + position: absolute; + left: 0; + width: 100%; + top: 50px; + bottom: 0px; + padding: 0; + margin: 0; + border-width: 0; + background: #000; /* opaque */ + z-index: 100; /* on top of all the camera stuff */ + transition: transform 0.5s linear; + overflow: hidden; + transform-origin: 0 0; +} + +#preview.offscreen { + transform: translateY(-100%) scale(.125) translateX(50%); +} + +#frame-container { + position: absolute; + left: 0; + top: 0; + width: 100%; + bottom: 40px; + padding: 0px; + margin: 0px; + overflow: hidden; + -moz-user-select: none; +} + +#media-frame { + position: absolute; + /* size, position, and rotation are set based on the phone orientation */ +} + +#media-frame > img { + top: 0px; /* javascript modifies this position with a transform */ + left: 0px; + position: absolute; + border-width: 0px; + padding: 0px; + margin: 0px; + transform-origin: 0px 0px; + pointer-events: none; + -moz-user-select: none; +} + +/* + * these styes apply when we're swapping out a preview image to replace + * it with a full-resolution image, but the full image isn't ready yet. + * This happens when the user starts to zoom in on the image. We need + * some sort of simple visual effect to fill ~500ms of dead time so the + * user doesn't think the app has frozen up + */ +#media-frame > img.swapping { + opacity: 0.8; + outline: dashed #0ac 4px; + outline-offset: -4px; +} + +#media-frame > video { + transform-origin: 0px 0px; +} + +#preview-controls { + position: absolute; + left: 0; + right: 0; + bottom: 0px; + height: 40px; + background-color: rgba(0, 0, 0, 0.8); + z-index: 100; /* above the dynamically inserted frame elements */ +} + +a.button { + display: block; + padding: 0; + margin: 0; + border-width: 0; + background-position: center center; + background-repeat: no-repeat; + transition: 0.2s ease-in-out; +} + +a.button:active, a.button:focus { + outline: none; +} + +a.button.hidden { + display: none; +} + +#camera-button { + position: absolute; + left: 0; + width: 33%; + height: 100%; + background-image: url(images/camera.png); +} + +#share-button { + position: absolute; + left: 33%; + width: 33%; + height: 100%; + background-image: url(images/share.png); +} + +#delete-button { + position: absolute; + left: 67%; + width: 33%; + height: 100%; + background-image: url(images/delete.png); +} + +#filmstrip-gallery-button { + position: absolute; + right: 0; + top: 0; + width: 50px; + height: 50px; + background-image: url(images/grid.png); +} + +/* + * Make the button icons rotate with the phone + */ +#preview[data-orientation="90"] a.button { + transform: rotate(-90deg); +} +#preview[data-orientation="180"] a.button { + transform: rotate(-180deg); +} +#preview[data-orientation="270"] a.button { + transform: rotate(-270deg); +} diff --git a/apps/system/camera/style/icons/60/Camera.png b/apps/system/camera/style/icons/60/Camera.png Binary files differnew file mode 100644 index 0000000..f27f506 --- /dev/null +++ b/apps/system/camera/style/icons/60/Camera.png diff --git a/apps/system/camera/style/icons/Camera.png b/apps/system/camera/style/icons/Camera.png Binary files differnew file mode 100644 index 0000000..f27f506 --- /dev/null +++ b/apps/system/camera/style/icons/Camera.png diff --git a/apps/system/camera/style/images/actionicon_cancel.png b/apps/system/camera/style/images/actionicon_cancel.png Binary files differnew file mode 100644 index 0000000..5e73322 --- /dev/null +++ b/apps/system/camera/style/images/actionicon_cancel.png diff --git a/apps/system/camera/style/images/camera.png b/apps/system/camera/style/images/camera.png Binary files differnew file mode 100644 index 0000000..85a80f5 --- /dev/null +++ b/apps/system/camera/style/images/camera.png diff --git a/apps/system/camera/style/images/delete.png b/apps/system/camera/style/images/delete.png Binary files differnew file mode 100644 index 0000000..0f2450e --- /dev/null +++ b/apps/system/camera/style/images/delete.png diff --git a/apps/system/camera/style/images/flash_auto.png b/apps/system/camera/style/images/flash_auto.png Binary files differnew file mode 100644 index 0000000..3018d8d --- /dev/null +++ b/apps/system/camera/style/images/flash_auto.png diff --git a/apps/system/camera/style/images/flash_off.png b/apps/system/camera/style/images/flash_off.png Binary files differnew file mode 100644 index 0000000..0fc7112 --- /dev/null +++ b/apps/system/camera/style/images/flash_off.png diff --git a/apps/system/camera/style/images/flash_on.png b/apps/system/camera/style/images/flash_on.png Binary files differnew file mode 100644 index 0000000..c7983d1 --- /dev/null +++ b/apps/system/camera/style/images/flash_on.png diff --git a/apps/system/camera/style/images/flash_torch.png b/apps/system/camera/style/images/flash_torch.png Binary files differnew file mode 100644 index 0000000..3018d8d --- /dev/null +++ b/apps/system/camera/style/images/flash_torch.png diff --git a/apps/system/camera/style/images/grid.png b/apps/system/camera/style/images/grid.png Binary files differnew file mode 100644 index 0000000..b53bcf2 --- /dev/null +++ b/apps/system/camera/style/images/grid.png diff --git a/apps/system/camera/style/images/hud_button_underlay.png b/apps/system/camera/style/images/hud_button_underlay.png Binary files differnew file mode 100644 index 0000000..5853adb --- /dev/null +++ b/apps/system/camera/style/images/hud_button_underlay.png diff --git a/apps/system/camera/style/images/hud_button_underlay_focus.png b/apps/system/camera/style/images/hud_button_underlay_focus.png Binary files differnew file mode 100644 index 0000000..c3542bc --- /dev/null +++ b/apps/system/camera/style/images/hud_button_underlay_focus.png diff --git a/apps/system/camera/style/images/play_overlay.png b/apps/system/camera/style/images/play_overlay.png Binary files differnew file mode 100644 index 0000000..2a56d04 --- /dev/null +++ b/apps/system/camera/style/images/play_overlay.png diff --git a/apps/system/camera/style/images/share.png b/apps/system/camera/style/images/share.png Binary files differnew file mode 100644 index 0000000..6a56f19 --- /dev/null +++ b/apps/system/camera/style/images/share.png diff --git a/apps/system/camera/style/images/stop.png b/apps/system/camera/style/images/stop.png Binary files differnew file mode 100644 index 0000000..b358cc5 --- /dev/null +++ b/apps/system/camera/style/images/stop.png diff --git a/apps/system/camera/style/images/toggle_back.png b/apps/system/camera/style/images/toggle_back.png Binary files differnew file mode 100644 index 0000000..5e767b4 --- /dev/null +++ b/apps/system/camera/style/images/toggle_back.png diff --git a/apps/system/camera/style/images/toggle_front.png b/apps/system/camera/style/images/toggle_front.png Binary files differnew file mode 100644 index 0000000..b67507f --- /dev/null +++ b/apps/system/camera/style/images/toggle_front.png diff --git a/apps/system/camera/style/images/ui/gradient.png b/apps/system/camera/style/images/ui/gradient.png Binary files differnew file mode 100644 index 0000000..b288545 --- /dev/null +++ b/apps/system/camera/style/images/ui/gradient.png diff --git a/apps/system/camera/style/images/ui/pattern.png b/apps/system/camera/style/images/ui/pattern.png Binary files differnew file mode 100644 index 0000000..af03f56 --- /dev/null +++ b/apps/system/camera/style/images/ui/pattern.png diff --git a/apps/system/camera/style/images/video.png b/apps/system/camera/style/images/video.png Binary files differnew file mode 100644 index 0000000..5c41986 --- /dev/null +++ b/apps/system/camera/style/images/video.png diff --git a/apps/system/camera/style/images/video_pause_button.png b/apps/system/camera/style/images/video_pause_button.png Binary files differnew file mode 100644 index 0000000..b0224f8 --- /dev/null +++ b/apps/system/camera/style/images/video_pause_button.png diff --git a/apps/system/camera/style/images/video_play_button.png b/apps/system/camera/style/images/video_play_button.png Binary files differnew file mode 100644 index 0000000..56dba6b --- /dev/null +++ b/apps/system/camera/style/images/video_play_button.png diff --git a/apps/system/camera/style/images/video_play_focus.png b/apps/system/camera/style/images/video_play_focus.png Binary files differnew file mode 100644 index 0000000..1bb0537 --- /dev/null +++ b/apps/system/camera/style/images/video_play_focus.png diff --git a/apps/system/camera/style/images/video_play_normal.png b/apps/system/camera/style/images/video_play_normal.png Binary files differnew file mode 100644 index 0000000..0cabf3d --- /dev/null +++ b/apps/system/camera/style/images/video_play_normal.png diff --git a/apps/system/camera/test/unit/_proxy.html b/apps/system/camera/test/unit/_proxy.html new file mode 100644 index 0000000..2102451 --- /dev/null +++ b/apps/system/camera/test/unit/_proxy.html @@ -0,0 +1,49 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta http-equiv="content-type" content="text/html; charset=utf-8" /> + <title>Serve the tests</title> + + <script type="text/javascript" charset="utf-8"> + (function(window){ + var Loader = window.CommonResourceLoader = {}, + host = document.location.host, + domain = host.replace(/(^[\w\d-]+\.)?([\w\d]+\.[a-z]+)/, 'test-agent.$2'); + + Loader.domain = document.location.protocol + '//' + domain; + Loader.url = function(url){ + return this.domain + url; + } + + Loader.script = function(url, doc){ + doc = doc || document; + doc.write('<script type="application/javascript;version=1.8" src="' + this.url(url) + '"><\/script>'); + return this; + }; + }(this)); + </script> + + <style type="text/css" media="all"> + html,body,iframe { + height: 100%; + width: 100%; + } + </style> + +</head> +<body> + +<!-- Test Agent UI will be loaded in here --> +<div id="test-agent-ui"> +</div> + +<script type="text/javascript" charset="utf-8"> +CommonResourceLoader. + script('/common/test/test_url_resolver.js'). + script('/common/vendor/test-agent/test-agent.js'). + script('/common/test/agent_proxy.js'); +</script> +</body> +</html> + + diff --git a/apps/system/camera/test/unit/_sandbox.html b/apps/system/camera/test/unit/_sandbox.html new file mode 100644 index 0000000..70d0efa --- /dev/null +++ b/apps/system/camera/test/unit/_sandbox.html @@ -0,0 +1,28 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta http-equiv="content-type" content="text/html; charset=utf-8" /> + <title>Tests</title> + <link rel="stylesheet" type="text/css" href="/vendor/mocha/mocha.css" /> + <style type="text/css" media="all"> + iframe { + border: none; + padding: 0px; + } + </style> + <script type="text/javascript" charset="utf-8"> + </script> +</head> + +<body> + +<div id="mocha"> +</div> + +<div id="test"> +</div> + +</body> +</html> + + |