Add voice notes

// FREEBIE
This commit is contained in:
lilia 2016-08-15 15:36:29 -07:00
parent cc15af549b
commit 4f46a164ba
22 changed files with 47762 additions and 14 deletions

View File

@ -6,6 +6,7 @@ module.exports = function(grunt) {
for (var i in bower.concat.app) {
components.push('components/' + bower.concat.app[i] + '/**/*.js');
}
components.push('components/' + 'webaudiorecorder/lib/WebAudioRecorder.js');
var libtextsecurecomponents = [];
for (i in bower.concat.libtextsecure) {
@ -86,6 +87,8 @@ module.exports = function(grunt) {
'Gruntfile.js',
'js/**/*.js',
'!js/libtextsecure.js',
'!js/WebAudioRecorderMp3.js',
'!js/Mp3LameEncoder.min.js',
'!js/libsignal-protocol-worker.js',
'!js/components.js',
'!js/signal_protocol_store.js',
@ -146,6 +149,8 @@ module.exports = function(grunt) {
'Gruntfile',
'js/**/*.js',
'!js/libtextsecure.js',
'!js/WebAudioRecorderMp3.js',
'!js/Mp3LameEncoder.min.js',
'!js/libsignal-protocol-worker.js',
'!js/components.js',
'test/**/*.js',

View File

@ -98,15 +98,25 @@
<div class='bottom-bar' id='footer'>
<form class='send clearfix'>
<div class='attachment-previews'></div>
<div class='choose-file'>
<button class='paperclip thumbnail'></button>
<input type='file' class='file-input'>
<div class='flex'>
<div class='choose-file'>
<button class='paperclip thumbnail'></button>
<input type='file' class='file-input'>
</div>
<textarea class='send-message' placeholder='{{ send-message }}' rows='1' dir='auto'></textarea>
<div class='capture-audio'>
<button class='microphone'></button>
</div>
</div>
<textarea class='send-message' placeholder='{{ send-message }}' rows='1' dir='auto'></textarea>
</form>
</div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='recorder'>
<button class='finish'><span class='icon'></span></button>
<span class='time'>0:00</span>
<button class='close'><span class='icon'></span></button>
</script>
<script type='text/x-tmpl-mustache' id='lightbox'>
<div class='content'>
<div class='controls'>
@ -467,6 +477,7 @@
<script type='text/javascript' src='js/views/message_detail_view.js'></script>
<script type='text/javascript' src='js/views/message_list_view.js'></script>
<script type='text/javascript' src='js/views/group_member_list_view.js'></script>
<script type='text/javascript' src='js/views/recorder_view.js'></script>
<script type='text/javascript' src='js/views/conversation_view.js'></script>
<script type='text/javascript' src='js/views/new_conversation_view.js'></script>
<script type='text/javascript' src='js/views/conversation_search_view.js'></script>

View File

@ -19,7 +19,9 @@
"blueimp-canvas-to-blob": "~2.1.1",
"twemoji": "~2.0.5",
"emojijs": "https://github.com/iamcal/js-emoji.git",
"autosize": "~3.0.6"
"autosize": "~3.0.6",
"webaudiorecorder": "https://github.com/higuma/web-audio-recorder-js.git",
"mp3lameencoder": "https://github.com/higuma/mp3-lame-encoder-js.git"
},
"devDependencies": {
"mocha": "~2.0.1",
@ -92,6 +94,13 @@
],
"autosize": [
"dist/autosize.js"
],
"webaudiorecorder": [
"lib/WebAudioRecorder.js",
"lib/WebAudioRecorderMp3.js"
],
"mp3lameencoder": [
"lib/Mp3LameEncoder.js"
]
},
"concat": {

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,97 @@
(function(window){
var WORKER_PATH = 'recorderWorker.js';
var Recorder = function(source, cfg){
var config = cfg || {};
var bufferLen = config.bufferLen || 4096;
this.context = source.context;
this.node = (this.context.createScriptProcessor ||
this.context.createJavaScriptNode).call(this.context,
bufferLen, 2, 2);
var worker = new Worker(config.workerPath || WORKER_PATH);
worker.postMessage({
command: 'init',
config: {
sampleRate: this.context.sampleRate
}
});
var recording = false,
currCallback;
var self = this;
this.node.onaudioprocess = function(e){
if (!recording) return;
self.ondata && self.ondata(e.inputBuffer.getChannelData(0));
worker.postMessage({
command: 'record',
buffer: [
e.inputBuffer.getChannelData(0),
e.inputBuffer.getChannelData(1)
]
});
}
this.configure = function(cfg){
for (var prop in cfg){
if (cfg.hasOwnProperty(prop)){
config[prop] = cfg[prop];
}
}
}
this.record = function(){
recording = true;
}
this.stop = function(){
recording = false;
}
this.clear = function(){
worker.postMessage({ command: 'clear' });
}
this.getBuffer = function(cb) {
currCallback = cb || config.callback;
worker.postMessage({ command: 'getBuffer' })
}
this.exportWAV = function(cb, type){
currCallback = cb || config.callback;
type = type || config.type || 'audio/wav';
if (!currCallback) throw new Error('Callback not set');
worker.postMessage({
command: 'exportWAV',
type: type
});
}
this.shutdown = function(){
worker.terminate();
source.disconnect();
this.node.disconnect();
};
worker.onmessage = function(e){
var blob = e.data;
currCallback(blob);
}
source.connect(this.node);
this.node.connect(this.context.destination); //this should not be necessary
};
Recorder.forceDownload = function(blob, filename){
var url = (window.URL || window.webkitURL).createObjectURL(blob);
var link = window.document.createElement('a');
link.href = url;
link.download = filename || 'output.wav';
var click = document.createEvent("Event");
click.initEvent("click", true, true);
link.dispatchEvent(click);
}
window.Recorder = Recorder;
})(window);

View File

@ -0,0 +1,131 @@
var recLength = 0,
recBuffersL = [],
recBuffersR = [],
sampleRate;
this.onmessage = function(e){
switch(e.data.command){
case 'init':
init(e.data.config);
break;
case 'record':
record(e.data.buffer);
break;
case 'exportWAV':
exportWAV(e.data.type);
break;
case 'getBuffer':
getBuffer();
break;
case 'clear':
clear();
break;
}
};
function init(config){
sampleRate = config.sampleRate;
}
function record(inputBuffer){
recBuffersL.push(inputBuffer[0]);
recBuffersR.push(inputBuffer[1]);
recLength += inputBuffer[0].length;
}
function exportWAV(type){
var bufferL = mergeBuffers(recBuffersL, recLength);
var bufferR = mergeBuffers(recBuffersR, recLength);
var interleaved = interleave(bufferL, bufferR);
var dataview = encodeWAV(interleaved);
var audioBlob = new Blob([dataview], { type: type });
this.postMessage(audioBlob);
}
function getBuffer() {
var buffers = [];
buffers.push( mergeBuffers(recBuffersL, recLength) );
buffers.push( mergeBuffers(recBuffersR, recLength) );
this.postMessage(buffers);
}
function clear(){
recLength = 0;
recBuffersL = [];
recBuffersR = [];
}
function mergeBuffers(recBuffers, recLength){
var result = new Float32Array(recLength);
var offset = 0;
for (var i = 0; i < recBuffers.length; i++){
result.set(recBuffers[i], offset);
offset += recBuffers[i].length;
}
return result;
}
function interleave(inputL, inputR){
var length = inputL.length + inputR.length;
var result = new Float32Array(length);
var index = 0,
inputIndex = 0;
while (index < length){
result[index++] = inputL[inputIndex];
result[index++] = inputR[inputIndex];
inputIndex++;
}
return result;
}
function floatTo16BitPCM(output, offset, input){
for (var i = 0; i < input.length; i++, offset+=2){
var s = Math.max(-1, Math.min(1, input[i]));
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
}
function writeString(view, offset, string){
for (var i = 0; i < string.length; i++){
view.setUint8(offset + i, string.charCodeAt(i));
}
}
function encodeWAV(samples){
var buffer = new ArrayBuffer(44 + samples.length * 2);
var view = new DataView(buffer);
/* RIFF identifier */
writeString(view, 0, 'RIFF');
/* RIFF chunk length */
view.setUint32(4, 36 + samples.length * 2, true);
/* RIFF type */
writeString(view, 8, 'WAVE');
/* format chunk identifier */
writeString(view, 12, 'fmt ');
/* format chunk length */
view.setUint32(16, 16, true);
/* sample format (raw) */
view.setUint16(20, 1, true);
/* channel count */
view.setUint16(22, 2, true);
/* sample rate */
view.setUint32(24, sampleRate, true);
/* byte rate (sample rate * block align) */
view.setUint32(28, sampleRate * 4, true);
/* block align (channel count * bytes per sample) */
view.setUint16(32, 4, true);
/* bits per sample */
view.setUint16(34, 16, true);
/* data chunk identifier */
writeString(view, 36, 'data');
/* data chunk length */
view.setUint32(40, samples.length * 2, true);
floatTo16BitPCM(view, 44, samples);
return view;
}

View File

@ -0,0 +1,199 @@
(function(window) {
// internal: same as jQuery.extend(true, args...)
var extend = function() {
var target = arguments[0],
sources = [].slice.call(arguments, 1);
for (var i = 0; i < sources.length; ++i) {
var src = sources[i];
for (key in src) {
var val = src[key];
target[key] = typeof val === "object"
? extend(typeof target[key] === "object" ? target[key] : {}, val)
: val;
}
}
return target;
};
var WORKER_FILE = {
wav: "WebAudioRecorderWav.js",
ogg: "WebAudioRecorderOgg.js",
mp3: "WebAudioRecorderMp3.js"
};
// default configs
var CONFIGS = {
workerDir: "/", // worker scripts dir (end with /)
numChannels: 2, // number of channels
encoding: "wav", // encoding (can be changed at runtime)
// runtime options
options: {
timeLimit: 300, // recording time limit (sec)
encodeAfterRecord: false, // process encoding after recording
progressInterval: 1000, // encoding progress report interval (millisec)
bufferSize: undefined, // buffer size (use browser default)
// encoding-specific options
wav: {
mimeType: "audio/wav"
},
ogg: {
mimeType: "audio/ogg",
quality: 0.5 // (VBR only): quality = [-0.1 .. 1]
},
mp3: {
mimeType: "audio/mpeg",
bitRate: 160 // (CBR only): bit rate = [64 .. 320]
}
}
};
// constructor
var WebAudioRecorder = function(sourceNode, configs) {
extend(this, CONFIGS, configs || {});
this.context = sourceNode.context;
if (this.context.createScriptProcessor == null)
this.context.createScriptProcessor = this.context.createJavaScriptNode;
this.input = this.context.createGain();
sourceNode.connect(this.input);
this.buffer = [];
this.initWorker();
};
// instance methods
extend(WebAudioRecorder.prototype, {
isRecording: function() { return this.processor != null; },
setEncoding: function(encoding) {
if (this.isRecording())
this.error("setEncoding: cannot set encoding during recording");
else if (this.encoding !== encoding) {
this.encoding = encoding;
this.initWorker();
}
},
setOptions: function(options) {
if (this.isRecording())
this.error("setOptions: cannot set options during recording");
else {
extend(this.options, options);
this.worker.postMessage({ command: "options", options: this.options });
}
},
startRecording: function() {
if (this.isRecording())
this.error("startRecording: previous recording is running");
else {
var numChannels = this.numChannels,
buffer = this.buffer,
worker = this.worker;
this.processor = this.context.createScriptProcessor(
this.options.bufferSize,
this.numChannels, this.numChannels);
this.input.connect(this.processor);
this.processor.connect(this.context.destination);
this.processor.onaudioprocess = function(event) {
for (var ch = 0; ch < numChannels; ++ch)
buffer[ch] = event.inputBuffer.getChannelData(ch);
worker.postMessage({ command: "record", buffer: buffer });
};
this.worker.postMessage({
command: "start",
bufferSize: this.processor.bufferSize
});
this.startTime = Date.now();
}
},
recordingTime: function() {
return this.isRecording() ? (Date.now() - this.startTime) * 0.001 : null;
},
cancelRecording: function() {
if (this.isRecording()) {
this.input.disconnect();
this.processor.disconnect();
delete this.processor;
this.worker.postMessage({ command: "cancel" });
} else
this.error("cancelRecording: no recording is running");
},
finishRecording: function() {
if (this.isRecording()) {
this.input.disconnect();
this.processor.disconnect();
delete this.processor;
this.worker.postMessage({ command: "finish" });
} else
this.error("finishRecording: no recording is running");
},
cancelEncoding: function() {
if (this.options.encodeAfterRecord)
if (this.isRecording())
this.error("cancelEncoding: recording is not finished");
else {
this.onEncodingCanceled(this);
this.initWorker();
}
else
this.error("cancelEncoding: invalid method call");
},
initWorker: function() {
if (this.worker != null)
this.worker.terminate();
this.onEncoderLoading(this, this.encoding);
this.worker = new Worker(this.workerDir + WORKER_FILE[this.encoding]);
var _this = this;
this.worker.onmessage = function(event) {
var data = event.data;
switch (data.command) {
case "loaded":
_this.onEncoderLoaded(_this, _this.encoding);
break;
case "timeout":
_this.onTimeout(_this);
break;
case "progress":
_this.onEncodingProgress(_this, data.progress);
break;
case "complete":
_this.onComplete(_this, data.blob);
break;
case "error":
_this.error(data.message);
}
};
this.worker.postMessage({
command: "init",
config: {
sampleRate: this.context.sampleRate,
numChannels: this.numChannels
},
options: this.options
});
},
error: function(message) {
this.onError(this, "WebAudioRecorder.js:" + message);
},
// event handlers
onEncoderLoading: function(recorder, encoding) {},
onEncoderLoaded: function(recorder, encoding) {},
onTimeout: function(recorder) { recorder.finishRecording(); },
onEncodingProgress: function (recorder, progress) {},
onEncodingCanceled: function(recorder) {},
onComplete: function(recorder, blob) {
recorder.onError(recorder, "WebAudioRecorder.js: You must override .onComplete event");
},
onError: function(recorder, message) { console.log(message); }
});
window.WebAudioRecorder = WebAudioRecorder;
})(window);

View File

@ -0,0 +1,91 @@
importScripts("Mp3LameEncoder.min.js");
var NUM_CH = 2, // constant
sampleRate = 44100,
options = undefined,
maxBuffers = undefined,
encoder = undefined,
recBuffers = undefined,
bufferCount = 0;
function error(message) {
self.postMessage({ command: "error", message: "mp3: " + message });
}
function init(data) {
if (data.config.numChannels === NUM_CH) {
sampleRate = data.config.sampleRate;
options = data.options;
} else
error("numChannels must be " + NUM_CH);
};
function setOptions(opt) {
if (encoder || recBuffers)
error("cannot set options during recording");
else
options = opt;
}
function start(bufferSize) {
maxBuffers = Math.ceil(options.timeLimit * sampleRate / bufferSize);
if (options.encodeAfterRecord)
recBuffers = [];
else
encoder = new Mp3LameEncoder(sampleRate, options.mp3.bitRate);
}
function record(buffer) {
if (bufferCount++ < maxBuffers)
if (encoder)
encoder.encode(buffer);
else
recBuffers.push(buffer);
else
self.postMessage({ command: "timeout" });
};
function postProgress(progress) {
self.postMessage({ command: "progress", progress: progress });
};
function finish() {
if (recBuffers) {
postProgress(0);
encoder = new Mp3LameEncoder(sampleRate, options.mp3.bitRate);
var timeout = Date.now() + options.progressInterval;
while (recBuffers.length > 0) {
encoder.encode(recBuffers.shift());
var now = Date.now();
if (now > timeout) {
postProgress((bufferCount - recBuffers.length) / bufferCount);
timeout = now + options.progressInterval;
}
}
postProgress(1);
}
self.postMessage({
command: "complete",
blob: encoder.finish(options.mp3.mimeType)
});
cleanup();
};
function cleanup() {
encoder = recBuffers = undefined;
bufferCount = 0;
}
self.onmessage = function(event) {
var data = event.data;
switch (data.command) {
case "init": init(data); break;
case "options": setOptions(data.options); break;
case "start": start(data.bufferSize); break;
case "record": record(data.buffer); break;
case "finish": finish(); break;
case "cancel": cleanup();
}
};
self.postMessage({ command: "loaded" });

1
images/microphone.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><path d="M24 28c3.31 0 5.98-2.69 5.98-6L30 10c0-3.32-2.68-6-6-6-3.31 0-6 2.68-6 6v12c0 3.31 2.69 6 6 6zm10.6-6c0 6-5.07 10.2-10.6 10.2-5.52 0-10.6-4.2-10.6-10.2H10c0 6.83 5.44 12.47 12 13.44V42h4v-6.56c6.56-.97 12-6.61 12-13.44h-3.4z"/></svg>

After

Width:  |  Height:  |  Size: 325 B

1
js/Mp3LameEncoder.min.js vendored Symbolic link
View File

@ -0,0 +1 @@
../components/mp3lameencoder/lib/Mp3LameEncoder.js

1
js/WebAudioRecorderMp3.js Symbolic link
View File

@ -0,0 +1 @@
../components/webaudiorecorder/lib/WebAudioRecorderMp3.js

View File

@ -38595,4 +38595,203 @@ if (typeof exports === 'object') {
}
module.exports = autosize;
});
});
(function(window) {
// internal: same as jQuery.extend(true, args...)
var extend = function() {
var target = arguments[0],
sources = [].slice.call(arguments, 1);
for (var i = 0; i < sources.length; ++i) {
var src = sources[i];
for (key in src) {
var val = src[key];
target[key] = typeof val === "object"
? extend(typeof target[key] === "object" ? target[key] : {}, val)
: val;
}
}
return target;
};
var WORKER_FILE = {
wav: "WebAudioRecorderWav.js",
ogg: "WebAudioRecorderOgg.js",
mp3: "WebAudioRecorderMp3.js"
};
// default configs
var CONFIGS = {
workerDir: "/", // worker scripts dir (end with /)
numChannels: 2, // number of channels
encoding: "wav", // encoding (can be changed at runtime)
// runtime options
options: {
timeLimit: 300, // recording time limit (sec)
encodeAfterRecord: false, // process encoding after recording
progressInterval: 1000, // encoding progress report interval (millisec)
bufferSize: undefined, // buffer size (use browser default)
// encoding-specific options
wav: {
mimeType: "audio/wav"
},
ogg: {
mimeType: "audio/ogg",
quality: 0.5 // (VBR only): quality = [-0.1 .. 1]
},
mp3: {
mimeType: "audio/mpeg",
bitRate: 160 // (CBR only): bit rate = [64 .. 320]
}
}
};
// constructor
var WebAudioRecorder = function(sourceNode, configs) {
extend(this, CONFIGS, configs || {});
this.context = sourceNode.context;
if (this.context.createScriptProcessor == null)
this.context.createScriptProcessor = this.context.createJavaScriptNode;
this.input = this.context.createGain();
sourceNode.connect(this.input);
this.buffer = [];
this.initWorker();
};
// instance methods
extend(WebAudioRecorder.prototype, {
isRecording: function() { return this.processor != null; },
setEncoding: function(encoding) {
if (this.isRecording())
this.error("setEncoding: cannot set encoding during recording");
else if (this.encoding !== encoding) {
this.encoding = encoding;
this.initWorker();
}
},
setOptions: function(options) {
if (this.isRecording())
this.error("setOptions: cannot set options during recording");
else {
extend(this.options, options);
this.worker.postMessage({ command: "options", options: this.options });
}
},
startRecording: function() {
if (this.isRecording())
this.error("startRecording: previous recording is running");
else {
var numChannels = this.numChannels,
buffer = this.buffer,
worker = this.worker;
this.processor = this.context.createScriptProcessor(
this.options.bufferSize,
this.numChannels, this.numChannels);
this.input.connect(this.processor);
this.processor.connect(this.context.destination);
this.processor.onaudioprocess = function(event) {
for (var ch = 0; ch < numChannels; ++ch)
buffer[ch] = event.inputBuffer.getChannelData(ch);
worker.postMessage({ command: "record", buffer: buffer });
};
this.worker.postMessage({
command: "start",
bufferSize: this.processor.bufferSize
});
this.startTime = Date.now();
}
},
recordingTime: function() {
return this.isRecording() ? (Date.now() - this.startTime) * 0.001 : null;
},
cancelRecording: function() {
if (this.isRecording()) {
this.input.disconnect();
this.processor.disconnect();
delete this.processor;
this.worker.postMessage({ command: "cancel" });
} else
this.error("cancelRecording: no recording is running");
},
finishRecording: function() {
if (this.isRecording()) {
this.input.disconnect();
this.processor.disconnect();
delete this.processor;
this.worker.postMessage({ command: "finish" });
} else
this.error("finishRecording: no recording is running");
},
cancelEncoding: function() {
if (this.options.encodeAfterRecord)
if (this.isRecording())
this.error("cancelEncoding: recording is not finished");
else {
this.onEncodingCanceled(this);
this.initWorker();
}
else
this.error("cancelEncoding: invalid method call");
},
initWorker: function() {
if (this.worker != null)
this.worker.terminate();
this.onEncoderLoading(this, this.encoding);
this.worker = new Worker(this.workerDir + WORKER_FILE[this.encoding]);
var _this = this;
this.worker.onmessage = function(event) {
var data = event.data;
switch (data.command) {
case "loaded":
_this.onEncoderLoaded(_this, _this.encoding);
break;
case "timeout":
_this.onTimeout(_this);
break;
case "progress":
_this.onEncodingProgress(_this, data.progress);
break;
case "complete":
_this.onComplete(_this, data.blob);
break;
case "error":
_this.error(data.message);
}
};
this.worker.postMessage({
command: "init",
config: {
sampleRate: this.context.sampleRate,
numChannels: this.numChannels
},
options: this.options
});
},
error: function(message) {
this.onError(this, "WebAudioRecorder.js:" + message);
},
// event handlers
onEncoderLoading: function(recorder, encoding) {},
onEncoderLoaded: function(recorder, encoding) {},
onTimeout: function(recorder) { recorder.finishRecording(); },
onEncodingProgress: function (recorder, progress) {},
onEncodingCanceled: function(recorder) {},
onComplete: function(recorder, blob) {
recorder.onError(recorder, "WebAudioRecorder.js: You must override .onComplete event");
},
onError: function(recorder, message) { console.log(message); }
});
window.WebAudioRecorder = WebAudioRecorder;
})(window);

View File

@ -101,13 +101,40 @@
'click' : 'onClick',
'click .bottom-bar': 'focusMessageField',
'click .back': 'resetPanel',
'click .microphone': 'captureAudio',
'focus .send-message': 'focusBottomBar',
'change .file-input': 'toggleMicrophone',
'blur .send-message': 'unfocusBottomBar',
'loadMore .message-list': 'fetchMessages',
'close .menu': 'closeMenu',
'select .message-list .entry': 'messageDetail',
'force-resize': 'forceUpdateMessageFieldSize'
},
toggleMicrophone: function() {
if (this.$('.send-message').val().length > 0 || this.fileInput.hasFiles()) {
this.$('.capture-audio').hide();
} else {
this.$('.capture-audio').show();
}
},
captureAudio: function(e) {
e.preventDefault();
var view = new Whisper.RecorderView().render();
view.on('send', this.handleAudioCapture.bind(this));
view.on('closed', this.endCaptureAudio.bind(this));
view.$el.appendTo(this.$('.capture-audio'));
this.$('.send-message').attr('disabled','disabled');
this.$('.microphone').hide();
},
handleAudioCapture: function(blob) {
this.fileInput.file = blob;
this.fileInput.previewImages();
this.$('.bottom-bar form').submit();
},
endCaptureAudio: function() {
this.$('.send-message').removeAttr('disabled');
this.$('.microphone').show();
},
unfocusBottomBar: function() {
this.$('.bottom-bar form').removeClass('active');
@ -295,6 +322,7 @@
event.preventDefault();
return this.$('.bottom-bar form').submit();
}
this.toggleMicrophone();
this.view.measureScrollPosition();
window.autosize(this.$messageField);

View File

@ -248,6 +248,7 @@
this.$input.wrap('<form>').parent('form').trigger('reset');
this.$input.unwrap();
this.file = null;
this.$input.trigger('change');
},
openDropped: function(e) {

View File

@ -116,7 +116,7 @@
restartSignal : i18n('restartSignal'),
},
events: {
'click': 'closeMenu',
'click': 'onClick',
'click #header': 'focusHeader',
'click .conversation': 'focusConversation',
'click .global-menu .hamburger': 'toggleMenu',
@ -173,12 +173,22 @@
showLightbox: function(e) {
this.$el.append(e.target);
},
closeRecording: function(e) {
if (e && this.$(e.target).closest('.capture-audio').length > 0 ) {
return;
}
this.$('.conversation:first .audio-capture').trigger('close');
},
closeMenu: function(e) {
if (e && this.$(e.target).parent('.global-menu').length > 0 ) {
return;
}
this.$('.global-menu .menu-list').hide();
},
onClick: function(e) {
this.closeMenu(e);
this.closeRecording(e);
}
});

74
js/views/recorder_view.js Normal file
View File

@ -0,0 +1,74 @@
/*
* vim: ts=4:sw=4:expandtab
*/
(function () {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.RecorderView = Whisper.View.extend({
className: 'recorder clearfix',
templateName: 'recorder',
initialize: function() {
this.startTime = Date.now();
this.interval = setInterval(this.updateTime.bind(this), 1000);
this.start();
},
events: {
'click .close': 'close',
'click .finish': 'finish',
'close': 'close'
},
updateTime: function() {
var duration = moment.duration(Date.now() - this.startTime, 'ms');
var minutes = '' + Math.trunc(duration.asMinutes());
var seconds = '' + duration.seconds();
if (seconds.length < 2) {
seconds = '0' + seconds;
}
this.$('.time').text(minutes + ':' + seconds);
},
close: function() {
if (this.recorder.isRecording()) {
this.recorder.cancelRecording();
}
if (this.interval) {
clearInterval(this.interval);
}
this.source.disconnect();
if (this.context) {
this.context.close().then(function() {
console.log('audio context closed');
});
}
this.remove();
this.trigger('closed');
},
finish: function() {
this.recorder.finishRecording();
this.close();
},
handleBlob: function(recorder, blob) {
if (blob) {
this.trigger('send', blob);
}
},
start: function() {
this.context = new AudioContext();
this.input = this.context.createGain();
this.recorder = new WebAudioRecorder(this.input, {
encoding: 'mp3',
workerDir: 'js/' // must end with slash
});
this.recorder.onComplete = this.handleBlob.bind(this);
this.recorder.onError = this.onError;
navigator.webkitGetUserMedia({ audio: true }, function(stream) {
this.source = this.context.createMediaStreamSource(stream);
this.source.connect(this.input);
}.bind(this), this.onError);
this.recorder.startRecording();
},
onError: function(error) {
console.log(error);
}
});
})();

View File

@ -14,7 +14,8 @@
"notifications",
{"fileSystem": ["write"]},
"alarms",
"fullscreen"
"fullscreen",
"audioCapture"
],
"icons": {

View File

@ -496,7 +496,6 @@ li.entry .error-icon-container {
form.send {
background: #ffffff;
padding-bottom: 1px;
}
input, textarea {
@ -525,14 +524,22 @@ li.entry .error-icon-container {
}
}
}
.flex {
display: flex;
flex-direction: row;
.send-message {
flex-grow: 1;
}
}
.choose-file {
float: left;
height: 36px;
}
.send-message {
display: block;
width: calc(100% - #{$button-width});
max-height: 100px;
padding: 10px;
border: 0;
@ -541,7 +548,16 @@ li.entry .error-icon-container {
resize: none;
font-size: 1em;
font-family: inherit;
&[disabled=disabled] {
background: transparent;
}
}
.capture-audio {
float: right;
height: 36px;
}
}
.toast {

View File

@ -0,0 +1,88 @@
.capture-audio {
text-align: center;
.microphone {
height: 36px;
width: 36px;
text-align: center;
opacity: 0.5;
background: transparent;
padding: 0;
border: none;
&:focus, &:hover {
opacity: 1.0;
}
&:before {
content: '';
display: inline-block;
height: 100%;
width: 24px;
@include color-svg('/images/microphone.svg', $grey);
}
}
}
.recorder {
background: $grey_l;
button {
float: right;
width: 36px;
height: 36px;
border-radius: 36px;
margin-left: 5px;
opacity: 0.5;
text-align: center;
padding: 0;
&:focus, &:hover {
opacity: 1.0;
}
.icon {
display: inline-block;
width: $button-height;
height: $button-height;
}
}
.finish {
background: lighten($green, 20%);
border: 1px solid $green;
.icon { @include color-svg('/images/check.svg', $green); }
}
.close {
background: lighten($red, 20%);
border: 1px solid $red;
.icon { @include color-svg('/images/x.svg', $red); }
}
.time {
color: $grey;
float: right;
line-height: 36px;
padding: 0 10px;
@keyframes pulse {
0% { opacity:0; }
50% { opacity:1; }
100% { opacity:0; }
}
&::before {
content: '';
display: inline-block;
border-radius: 10px;
width: 10px;
height: 10px;
background: #f00;
margin-right: 10px;
opacity: 0;
animation: pulse 2s infinite;
}
}
}

View File

@ -7,6 +7,8 @@ $grey_l3: darken($grey_l, 20%);
$grey_l4: darken($grey_l, 40%);
$grey: #616161;
$grey_d: #454545;
$green: #47D647;
$red: #EF8989;
@font-face {
font-family: 'Roboto-Light';

View File

@ -540,6 +540,81 @@ input[type=text]:active, input[type=text]:focus, input[type=search]:active, inpu
-webkit-mask-size: 100%;
background-color: white; }
.capture-audio {
text-align: center; }
.capture-audio .microphone {
height: 36px;
width: 36px;
text-align: center;
opacity: 0.5;
background: transparent;
padding: 0;
border: none; }
.capture-audio .microphone:focus, .capture-audio .microphone:hover {
opacity: 1.0; }
.capture-audio .microphone:before {
content: '';
display: inline-block;
height: 100%;
width: 24px;
-webkit-mask: url("/images/microphone.svg") no-repeat center;
-webkit-mask-size: 100%;
background-color: #616161; }
.recorder {
background: #f3f3f3; }
.recorder button {
float: right;
width: 36px;
height: 36px;
border-radius: 36px;
margin-left: 5px;
opacity: 0.5;
text-align: center;
padding: 0; }
.recorder button:focus, .recorder button:hover {
opacity: 1.0; }
.recorder button .icon {
display: inline-block;
width: 24px;
height: 24px; }
.recorder .finish {
background: #9ae99a;
border: 1px solid #47D647; }
.recorder .finish .icon {
-webkit-mask: url("/images/check.svg") no-repeat center;
-webkit-mask-size: 100%;
background-color: #47D647; }
.recorder .close {
background: #fbe3e3;
border: 1px solid #EF8989; }
.recorder .close .icon {
-webkit-mask: url("/images/x.svg") no-repeat center;
-webkit-mask-size: 100%;
background-color: #EF8989; }
.recorder .time {
color: #616161;
float: right;
line-height: 36px;
padding: 0 10px; }
@keyframes pulse {
0% {
opacity: 0; }
50% {
opacity: 1; }
100% {
opacity: 0; } }
.recorder .time::before {
content: '';
display: inline-block;
border-radius: 10px;
width: 10px;
height: 10px;
background: #f00;
margin-right: 10px;
opacity: 0;
animation: pulse 2s infinite; }
.conversation-stack,
.new-conversation, .inbox, .gutter {
height: 100%; }
@ -1147,8 +1222,7 @@ li.entry .error-icon-container {
.bottom-bar form.active {
outline: solid 1px #2090ea; }
.bottom-bar form.send {
background: #ffffff;
padding-bottom: 1px; }
background: #ffffff; }
.bottom-bar input, .bottom-bar textarea {
color: #454545; }
.bottom-bar .attachment-previews {
@ -1166,12 +1240,16 @@ li.entry .error-icon-container {
background: #999; }
.bottom-bar .attachment-previews .close:hover {
background: #616161; }
.bottom-bar .flex {
display: flex;
flex-direction: row; }
.bottom-bar .flex .send-message {
flex-grow: 1; }
.bottom-bar .choose-file {
float: left;
height: 36px; }
.bottom-bar .send-message {
display: block;
width: calc(100% - 36px);
max-height: 100px;
padding: 10px;
border: 0;
@ -1180,6 +1258,11 @@ li.entry .error-icon-container {
resize: none;
font-size: 1em;
font-family: inherit; }
.bottom-bar .send-message[disabled=disabled] {
background: transparent; }
.bottom-bar .capture-audio {
float: right;
height: 36px; }
.toast {
position: absolute;

View File

@ -7,6 +7,7 @@
@import 'progress';
@import 'debugLog';
@import 'lightbox';
@import 'recorder';
// Build the main view
@import 'index';