├── .gitignore ├── .jshintrc ├── Gruntfile.js ├── README.md ├── lib ├── json-client.js ├── room.js └── server.js ├── package.json ├── page ├── index.html ├── js │ ├── eventListener.js │ ├── main.js │ └── recorder.js ├── jsx │ └── app.jsx ├── static │ ├── base.css │ ├── gh-fork-ribbon.css │ ├── logo.png │ ├── recorderWorker.js │ └── splash.jpg └── vendor │ ├── react.js │ ├── recorder.js │ ├── zepto.min.js │ └── zepto.xcookie.min.js └── tests └── test_room.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .dev/ 3 | dist/ 4 | node_modules/ 5 | npm-debug.log 6 | tmp/ 7 | build/ 8 | watchChanged.json 9 | config.json 10 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "curly": true, 3 | "eqeqeq": true, 4 | "immed": true, 5 | "latedef": true, 6 | "newcap": true, 7 | "noarg": true, 8 | "sub": true, 9 | "undef": true, 10 | "boss": true, 11 | "eqnull": true, 12 | "es5": true, 13 | "evil": true, 14 | "node": true, 15 | "browser": true, 16 | "strict": false 17 | } 18 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | var watchChanged = {} 3 | if (grunt.file.exists('watchChanged.json')) { 4 | watchChanged = grunt.file.readJSON('watchChanged.json') 5 | } 6 | grunt.initConfig({ 7 | pkg: grunt.file.readJSON('package.json'), 8 | react: { // just for jsxhint, production transform is done 9 | // by browserify 10 | dynamic_mappings: { 11 | files: [ 12 | { 13 | expand: true, 14 | cwd: 'page/jsx', 15 | src: ['*.jsx'], 16 | dest: 'tmp/jsx', 17 | ext: '.js' 18 | } 19 | ] 20 | } 21 | }, 22 | jshint: { 23 | changed : [], 24 | js: ['Gruntfile.js', 'lib/*.js', 'page/js/*.js', 'tests/*.js'], 25 | jsx : ['tmp/jsx/*.js'], 26 | options: { 27 | "browser": true, 28 | "globals": { 29 | "React" : true, 30 | "CodeMirror" : true, 31 | "confirm" : true 32 | }, 33 | "node" : true, 34 | "asi" : true, 35 | "globalstrict": false, 36 | "quotmark": false, 37 | "smarttabs": true, 38 | "trailing": false, 39 | "undef": true, 40 | "unused": false 41 | } 42 | }, 43 | node_tap: { 44 | all: { 45 | options: { 46 | outputType: 'failures', // tap, failures, stats 47 | outputTo: 'console' // or file 48 | // outputFilePath: '/tmp/out.log' // path for output file, 49 | // only makes sense with outputTo 'file' 50 | }, 51 | files: { 52 | 'tests': ['tests/*.js'] 53 | } 54 | }, 55 | changed: { 56 | options: { 57 | outputType: 'tap', // tap, failures, stats 58 | outputTo: 'console' // or file 59 | // outputFilePath: '/tmp/out.log' // path for output file, 60 | // only makes sense with outputTo 'file' 61 | }, 62 | files: { 63 | 'tests': watchChanged.node_tap || [] 64 | } 65 | } 66 | }, 67 | copy: { 68 | assets: { 69 | files: [ 70 | // includes files within path 71 | {expand: true, cwd: 'page/', src: ['*'], dest: 'build/', filter: 'isFile'}, 72 | 73 | // includes files within path and its sub-directories 74 | {expand: true, cwd: 'page/static', src: ['*.js', '*.jpg', '*.css'], dest: 'build/'} 75 | 76 | // makes all src relative to cwd 77 | // {expand: true, cwd: 'path/', src: ['**'], dest: 'dest/'}, 78 | 79 | // flattens results to a single level 80 | // {expand: true, flatten: true, src: ['path/**'], dest: 'dest/', filter: 'isFile'} 81 | ] 82 | } 83 | }, 84 | browserify: { 85 | options: { 86 | debug : true, 87 | transform: [ require('grunt-react').browserify ] 88 | }, 89 | app: { 90 | src: 'page/js/main.js', 91 | dest: 'build/bundle.js' 92 | } 93 | }, 94 | uglify: { 95 | options: { 96 | mangle: false, 97 | compress : { 98 | unused : false 99 | }, 100 | beautify : { 101 | ascii_only : true 102 | } 103 | }, 104 | assets: { 105 | files: { 106 | // 'build/bundle.min.js': ['build/bundle.js'], 107 | 'build/vendor.min.js': ['page/vendor/*.js'] 108 | } 109 | } 110 | }, 111 | imageEmbed: { 112 | dist: { 113 | src: [ "page/static/base.css" ], 114 | dest: "build/base.css", 115 | options: { 116 | deleteAfterEncoding : false 117 | } 118 | } 119 | }, 120 | // staticinline: { 121 | // main: { 122 | // files: { 123 | // 'build/index.html': 'build/index.html', 124 | // } 125 | // } 126 | // }, 127 | express: { 128 | options: { 129 | // Override defaults here 130 | // delay : 100, 131 | // background: false, 132 | debug: true 133 | }, 134 | dev: { 135 | options: { 136 | script: 'lib/server.js' 137 | } 138 | } 139 | }, 140 | watch: { 141 | scripts: { 142 | files: ['Gruntfile.js', 'lib/**/*.js', 'page/js/*.js'], 143 | tasks: ['jshint:changed', 'default'], 144 | options: { 145 | spawn: false, 146 | }, 147 | }, 148 | jsx: { 149 | files: ['page/jsx/*.jsx'], 150 | tasks: ['jsxhint', 'default'], 151 | options: { 152 | spawn: false, 153 | }, 154 | }, 155 | other : { 156 | files: ['page/**/*'], 157 | tasks: ['default'], 158 | options: { 159 | spawn: false, 160 | }, 161 | }, 162 | tests : { 163 | files: ['tests/*.js'], 164 | tasks: ['jshint:js', 'node_tap:changed', 'default'], 165 | options: { 166 | interrupt: true, 167 | }, 168 | }, 169 | express: { 170 | files: [ 'lib/**/*.js' ], 171 | tasks: [ 'express:dev' ], 172 | options: { 173 | spawn: false 174 | } 175 | } 176 | }, 177 | notify: { 178 | "watch": { 179 | options: { 180 | message: 'Assets compiled.', //required 181 | } 182 | } 183 | } 184 | }) 185 | grunt.loadNpmTasks('grunt-newer'); 186 | grunt.loadNpmTasks('grunt-browserify') 187 | grunt.loadNpmTasks('grunt-react'); 188 | grunt.loadNpmTasks('grunt-contrib-jshint'); 189 | grunt.loadNpmTasks('grunt-contrib-watch'); 190 | grunt.loadNpmTasks('grunt-contrib-copy'); 191 | grunt.loadNpmTasks('grunt-contrib-uglify'); 192 | grunt.loadNpmTasks('grunt-node-tap'); 193 | // grunt.loadNpmTasks('grunt-static-inline'); 194 | grunt.loadNpmTasks("grunt-image-embed"); 195 | grunt.loadNpmTasks('grunt-express-server'); 196 | grunt.loadNpmTasks('grunt-notify'); 197 | 198 | grunt.registerTask('jsxhint', ['newer:react', 'jshint:jsx']); 199 | grunt.registerTask('default', ['jshint:js', 'jsxhint', 'node_tap:all', 'build', 'notify']); 200 | 201 | grunt.registerTask("build", ['copy:assets', 'browserify', 'imageEmbed','uglify']) 202 | 203 | grunt.registerTask("dev", ["default", 'express:dev', 'watch']) 204 | 205 | grunt.event.on('watch', function(action, filepath) { 206 | // for (var key in require.cache) {delete require.cache[key];} 207 | grunt.config('jshint.changed', [filepath]); 208 | grunt.file.write("watchChanged.json", JSON.stringify({ 209 | node_tap : [filepath] 210 | })) 211 | grunt.config('node_tap.changed.files.tests', [filepath]); 212 | }); 213 | }; 214 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CouchTalk 2 | ## Push To Talk example app 3 | 4 | CouchTalk is an example app showing off node.js with Couchbase Server. To run the app, do this: 5 | 6 | git clone 7 | cd couchtalk-nodejs 8 | npm install 9 | npm install -g grunt-cli 10 | grunt build 11 | npm start 12 | 13 | The last command will launch the server by running `node lib/server.js` 14 | 15 | ## Contributing 16 | 17 | If you edit the files under `lib/` or `page/` you need to run this to get the changes to show up. 18 | 19 | npm install -g grunt-cli 20 | grunt dev 21 | 22 | This will repackage the assets and launch the server in the background. It also watches the source files for changes and regenerates the assets and relaunches the server when you save files. 23 | -------------------------------------------------------------------------------- /lib/json-client.js: -------------------------------------------------------------------------------- 1 | var requestLib = require("request"), 2 | request = requestLib.defaults({ 3 | json:true 4 | }, function(uri, options, callback){ 5 | var params = requestLib.initParams(uri, options, callback); 6 | // console.log("req", params.options) 7 | return requestLib(params.uri, params.options, function(err, res, body){ 8 | // console.log("requestLib", err, res.statusCode, params.uri) 9 | // treat bad status codes as errors 10 | if (!err && res.statusCode >= 400) { 11 | params.callback.apply(this, [res.statusCode, res, body]); 12 | } else { 13 | params.callback.apply(this, arguments); 14 | } 15 | }) 16 | }); 17 | 18 | module.exports = request; 19 | -------------------------------------------------------------------------------- /lib/room.js: -------------------------------------------------------------------------------- 1 | module.exports = function () { 2 | return { 3 | createMessage : function() {}, 4 | recentMessages : function() {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | var express = require('express'), 2 | path = require("path"), 3 | fs = require("fs"), 4 | couchbase = require('couchbase'), 5 | build = path.join(__dirname, '..', 'build'), 6 | index = path.join(build, "index.html"), 7 | app = express(), 8 | server = require('http').createServer(app), 9 | io = require('socket.io').listen(server), 10 | EventEmitter = require("events").EventEmitter, 11 | portNum = 3000; 12 | 13 | var ee = new EventEmitter(); 14 | 15 | try { 16 | var config = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'config.json'), "utf8")); 17 | } catch (e) { 18 | console.error(e) 19 | console.error("error loading config, using defaults") 20 | config = {bucket : "talk"} 21 | } 22 | 23 | console.log("connecting with config", config) 24 | var db = new couchbase.Connection(config, function(err) { 25 | if (err) throw err; 26 | console.log("connected") 27 | 28 | app.get("/loaderio-5298504f4a9c2e3dd950b17deb2f21bc/", function(req, res){ 29 | res.send("loaderio-5298504f4a9c2e3dd950b17deb2f21bc") 30 | }) 31 | 32 | app.get('/talk/:id', function(req, res){ 33 | res.status(200).sendfile(index) 34 | }); 35 | 36 | app.get("/snapshot/:id", function(req, res) { 37 | console.log("get snap", req.params.id) 38 | db.get(req.params.id, function(err, doc) { 39 | if (err) { 40 | res.status(404) 41 | res.json({error : "not_found"}) 42 | } else { 43 | res.set('Content-Type', 'image/jpeg'); 44 | res.set('Cache-Control', 'public, max-age=31536000'); 45 | res.send(doc.value) 46 | } 47 | }) 48 | }) 49 | 50 | app.get("/audio/:id", function(req, res) { 51 | console.log("get audio", req.params.id) 52 | db.get(req.params.id, function(err, doc) { 53 | if (err) { 54 | res.status(404) 55 | res.json({error : "not_found"}) 56 | } else { 57 | res.set('Content-Type', 'audio/wav'); 58 | res.set('Cache-Control', 'public, max-age=31536000'); 59 | res.send(doc.value) 60 | } 61 | }) 62 | }) 63 | 64 | app.post('/snapshot/:room_id/:snapshot_id/:keypress_id', function(req, res){ 65 | console.log("post snap", req.params, req.query) 66 | var data = ""; 67 | 68 | req.on('data', function(chunk) { 69 | data += chunk.toString(); 70 | }); 71 | 72 | req.on('end', function() { 73 | var ttl = req.query.selfDestruct, 74 | opts = {}; 75 | if (ttl) {opts.expiry = parseInt(ttl, 10)} 76 | db.add(req.params.snapshot_id, new Buffer(data, "base64"), opts, function(err, result) { 77 | if (err) { 78 | res.status(500) 79 | console.log("no_update", err) 80 | res.json({error : "no_update"}) 81 | } else { 82 | console.log("saved snap", req.params) 83 | ee.emit("room-"+req.params.room_id, { 84 | snap:req.params.snapshot_id, 85 | // keypressId:req.params.keypress_id, 86 | image : "true" 87 | }) 88 | res.json({ok:true}) 89 | } 90 | }); 91 | }) 92 | }); 93 | 94 | app.post('/audio/:room_id/:snapshot_id/:keypressId', function(req, res){ 95 | console.log("post audio", req.params, req.query) 96 | var data = ""; 97 | req.on('data', function(chunk) { 98 | data += chunk.toString(); 99 | }); 100 | 101 | req.on('end', function() { 102 | var id = req.params.snapshot_id + "-audio", 103 | ttl = req.query.selfDestruct, 104 | opts = {}; 105 | if (ttl) {opts.expiry = parseInt(ttl, 10)} 106 | console.log("audio id", id, opts) 107 | db.add(id, new Buffer(data, "base64"), opts, function(err, result) { 108 | if (err) { 109 | console.error(err) 110 | res.status(403) 111 | res.json({error : "no_update"}) 112 | } else { 113 | ee.emit("room-"+req.params.room_id, { 114 | snap: req.params.snapshot_id, 115 | audio: id}) 116 | res.json({ok:true, id : id}) 117 | } 118 | }); 119 | }) 120 | }); 121 | 122 | function getSnapshotId(room, cb) { 123 | db.incr("ct-"+room, {initial: 0}, function(err, result){ 124 | cb(err, ["snap",room,result.value].join('-')) 125 | }) 126 | } 127 | 128 | io.sockets.on('connection', function (socket) { 129 | socket.on('join', function (data) { 130 | console.log("join",data.room); 131 | ee.on("room-"+data.room, function(incoming) { 132 | console.log("ee emitted",incoming); 133 | socket.emit("message", incoming) 134 | }) 135 | getSnapshotId(data.room, function(err, id){ 136 | data.snap = id; 137 | console.log("snap id", data) 138 | // socket.emit("snap-id",data) 139 | ee.emit("room-"+data.room, data) 140 | }) 141 | }); 142 | // we need the session id to be part of the message 143 | socket.on('new-snap', function (data) { 144 | getSnapshotId(data.room, function(err, id){ 145 | data.snap = id; 146 | socket.emit("message",data) 147 | }) 148 | }) 149 | }); 150 | 151 | app.enable('trust proxy') 152 | app.use(express.static(build)) 153 | 154 | console.log("listening on port", portNum) 155 | server.listen(portNum); 156 | }); 157 | 158 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "CouchTalk", 3 | "scripts": { 4 | "start" : "node lib/server.js" 5 | }, 6 | "dependencies": { 7 | "couchbase": "1.2.*", 8 | "express": "3.4.*", 9 | "async" : "0.2.10", 10 | "request" : "~2.34.0", 11 | "getusermedia" : "0.2.1", 12 | "socket.io" : "0.9.*" 13 | }, 14 | "devDependencies": { 15 | "grunt": "~0.4.2", 16 | "grunt-contrib-jshint": "~0.6.3", 17 | "grunt-contrib-uglify": "~0.2.7", 18 | "grunt-react": "~0.6.0", 19 | "browserify": "~3.19.1", 20 | "grunt-browserify": "~1.3.0", 21 | "grunt-newer": "~0.6.0", 22 | "grunt-contrib-watch": "~0.5.3", 23 | "grunt-contrib-copy": "~0.5.0", 24 | "grunt-node-tap": "~0.1.52", 25 | "tap": "~0.4.8", 26 | "tape": "~2.3.2", 27 | "grunt-image-embed": "~0.3.1", 28 | "grunt-express-server": "^0.4.13", 29 | "grunt-notify": "^0.2.17" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /page/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CouchTalk with Couchbase 6 | 7 | 8 | 9 | 10 |
11 |
12 |

Welcome to CouchTalk

13 | 14 |
15 |
16 |
17 |
18 | Fork me on GitHub 19 |
20 |
21 | 22 | 23 | 24 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /page/js/eventListener.js: -------------------------------------------------------------------------------- 1 | // Apache 2.0 License http://www.apache.org/licenses/LICENSE-2.0.html 2 | // Copywrite 2014 Couchbase, Inc. 3 | 4 | module.exports = { 5 | listen : function(emitter, event, handler) { 6 | // console.log("listen", event) 7 | var mixinStateKeyForEvent = "_EventListenerMixinState:"+event; 8 | var sub = this.state[mixinStateKeyForEvent] || {}; 9 | if (sub.event && sub.emitter) { 10 | if (sub.event == event && sub.emitter === emitter) { 11 | // we are already listening, noop 12 | // console.log("EventListenerMixin alreadyListening", sub.event, this) 13 | return; 14 | } else { 15 | // unsubscribe from the existing one 16 | // console.log("EventListenerMixin removeListener", sub.event, this) 17 | sub.emitter.removeListener(sub.event, sub.handler) 18 | } 19 | } 20 | var mixinState = { 21 | emitter : emitter, 22 | event : event, 23 | handler : handler 24 | } 25 | // console.log("EventListenerMixin addListener", event, this, mixinState) 26 | var stateToMerge = {}; 27 | stateToMerge[mixinStateKeyForEvent] = mixinState; 28 | this.setState(stateToMerge); 29 | emitter.on(event, handler) 30 | }, 31 | componentWillUnmount : function() { 32 | // console.log("componentWillUnmount", JSON.stringify(this.state)) 33 | for (var eventKey in this.state) { 34 | var ekps = eventKey.split(":") 35 | if (ekps[0] == "_EventListenerMixinState") { 36 | var sub = this.state[eventKey] 37 | var emitter = sub.emitter 38 | // console.log("EventListenerMixin Unmount removeListener", eventKey, sub, this) 39 | emitter.removeListener(sub.event, sub.handler) 40 | } 41 | } 42 | }, 43 | } 44 | -------------------------------------------------------------------------------- /page/js/main.js: -------------------------------------------------------------------------------- 1 | /* global $ */ 2 | var CouchTalk = require("../jsx/app.jsx"); 3 | 4 | $(function () { 5 | var match = /\/talk\/(.*)/.exec(location.pathname); 6 | if (match) { 7 | React.renderComponent( 8 | CouchTalk.App({id : match[1]}), 9 | document.getElementById('container') 10 | ); 11 | } else { 12 | React.renderComponent(CouchTalk.Index({}), 13 | document.getElementById("container")) 14 | } 15 | }) 16 | -------------------------------------------------------------------------------- /page/js/recorder.js: -------------------------------------------------------------------------------- 1 | /* global Recorder */ 2 | /* global AudioContext */ 3 | 4 | module.exports = { 5 | connectAudio : connectAudio 6 | } 7 | 8 | function connectAudio(cb) { 9 | window.AudioContext = window.AudioContext || window.webkitAudioContext; 10 | navigator.getUserMedia = navigator.getUserMedia || 11 | navigator.webkitGetUserMedia || 12 | navigator.mozGetUserMedia || 13 | navigator.msGetUserMedia; 14 | window.URL = window.URL || window.webkitURL; 15 | 16 | if (!navigator.getUserMedia) { 17 | cb(new Error("navigator.getUserMedia missing")) 18 | } else { 19 | try { 20 | var audio_context = new AudioContext(); 21 | } catch (e) { 22 | cb(new Error("AudioContext missing")) 23 | } 24 | navigator.getUserMedia({audio: true, video: true}, function(stream){ 25 | var input = audio_context.createMediaStreamSource(stream), 26 | recorder = new Recorder(input, {workerPath: "/recorderWorker.js"}); 27 | recorder.stream = stream; 28 | cb(false, recorder) 29 | }, function(e) { 30 | cb(new Error('No live audio input: ' + e)); 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /page/jsx/app.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jsx React.DOM 3 | */ 4 | /* global $ */ 5 | /* global io */ 6 | 7 | 8 | var 9 | connectAudio = require("../js/recorder").connectAudio, 10 | getUserMedia = require("getusermedia"); 11 | 12 | module.exports.App = React.createClass({ 13 | propTypes : { 14 | id : React.PropTypes.string.isRequired, 15 | }, 16 | getInitialState : function(){ 17 | // console.log($.fn.cookie("autoplay"), $.fn.cookie("selfDestruct"), $.fn.cookie("selfDestructTTL")) 18 | var start = getQueryVariable("start"); 19 | var end = getQueryVariable("end"); 20 | // console.log(start, end) 21 | return { 22 | recording: false, 23 | messages : [], 24 | session : "s:"+Math.random().toString(20), 25 | autoplay : $.fn.cookie('autoplay') !== "false", 26 | selfDestructTTL : parseInt($.fn.cookie('selfDestructTTL'), 10) || 300, 27 | selfDestruct : $.fn.cookie('selfDestruct') === "true", 28 | nowPlaying : false, 29 | start : parseInt(start, 10), 30 | end : parseInt(end, 10) 31 | } 32 | }, 33 | componentWillMount: function() { 34 | var socket = io.connect(location.origin) 35 | socket.on("message", this.gotMessage) 36 | this.setState({socket : socket}) 37 | }, 38 | gotMessage : function(message){ 39 | var messages = this.state.messages; 40 | // console.log("message", message, messages.length) 41 | 42 | if (message.snap) { 43 | for (var i = messages.length - 1; i >= 0; i--) { 44 | if (messages[i] && messages[i].snap && messages[i].snap.split(":")[0] === message.snap.split(":")[0]) { 45 | break; 46 | } 47 | } 48 | if (messages[i]) { 49 | // exists in some form 50 | $.extend(messages[i], message) 51 | } else { 52 | // check to see if there's a message with the keypress 53 | if (message.keypressId) { 54 | var findIndex = {}; 55 | this.messageForKeypress(message.keypressId, findIndex) 56 | if (findIndex.i) { 57 | $.extend(messages[findIndex.i], message) 58 | i = findIndex.i 59 | } else { 60 | // first time, add it 61 | messages.push(message) 62 | i = messages.length-1; 63 | } 64 | } else { 65 | // first time, add it 66 | messages.push(message) 67 | i = messages.length-1; 68 | } 69 | 70 | } 71 | this.setState({messages : messages}) 72 | this.maybePlay(messages[i], i) 73 | } else { 74 | // console.log("no snap only keypressId", message) 75 | } 76 | }, 77 | maybePlay : function(message, i) { 78 | // console.log(this.state, message) 79 | if (this.state.autoplay && this.state.nowPlaying === false) { 80 | if (this.state.session !== message.session) { 81 | this.playMessage(i) 82 | } 83 | } 84 | }, 85 | listenForSpaceBar : function(){ 86 | // record while spacebar is down 87 | window.onkeydown = function (e) { 88 | var code = e.keyCode ? e.keyCode : e.which; 89 | if (code === 32) { //spacebar 90 | e.preventDefault() 91 | this.startRecord("kp:"+Math.random().toString(20)) 92 | } 93 | }.bind(this); 94 | window.onkeyup = function (e) { 95 | var code = e.keyCode ? e.keyCode : e.which; 96 | if (code === 32) { // spacebar 97 | this.stopRecord() 98 | } 99 | }.bind(this); 100 | }, 101 | startRecord : function(keypressId) { 102 | if (this.state.recording) return; 103 | // console.log("startRecord",keypressId) 104 | this.state.recorder.record() 105 | var counter = 0; 106 | this.takeSnapshot(keypressId, counter) 107 | var interval = setInterval(function(){ 108 | this.takeSnapshot(keypressId, counter++) 109 | }.bind(this), 250) 110 | var video = $("video") 111 | video.addClass("recording") 112 | video.data("keypressId", keypressId) 113 | this.setState({recording : true, pictureInterval : interval}); 114 | this.state.socket.emit("new-snap", { 115 | keypressId : keypressId, 116 | session : this.state.session, 117 | room : this.props.id 118 | }) 119 | }, 120 | stopRecord : function() { 121 | if (this.state.pictureInterval) clearInterval(this.state.pictureInterval) 122 | if (!this.state.recording) { 123 | return console.error("I thought I was recording!") 124 | } 125 | var video = $("video"), 126 | keypressId = video.data("keypressId"), 127 | recorder = this.state.recorder; 128 | recorder.stop() 129 | video.removeClass("recording"); 130 | var message = this.messageForKeypress(keypressId); 131 | if (message) { 132 | delete message.snapdata; 133 | } 134 | 135 | recorder.exportMonoWAV(this.saveAudio.bind(this, keypressId)) 136 | recorder.clear() 137 | this.setState({recording : false}) 138 | // console.log("stopped recording", keypressId) 139 | }, 140 | saveAudio : function(keypressId, wav){ 141 | this.messageWithIdForKeypress(keypressId, 142 | function(message){ 143 | var reader = new FileReader(), 144 | postURL = "/audio/" + this.props.id + "/" + message.snap.split(":")[0] + "/" + keypressId; 145 | if (this.state.selfDestruct) { 146 | postURL+= "?selfDestruct="+this.state.selfDestructTTL; 147 | } 148 | reader.addEventListener("loadend", function() { 149 | var parts = reader.result.split(/[,;:]/) 150 | $.ajax({ 151 | type : "POST", 152 | url : postURL, 153 | contentType : parts[1], 154 | data : parts[3], 155 | success : function() { 156 | // console.log("saved audio", message) 157 | } 158 | }) 159 | }.bind(this)); 160 | reader.readAsDataURL(wav); 161 | }.bind(this)) 162 | }, 163 | takeSnapshot : function(keypressId, counter){ 164 | var rootNode = $(this.getDOMNode()); 165 | var canvas = rootNode.find("canvas")[0]; 166 | var video = rootNode.find("video")[0]; 167 | var ctx = canvas.getContext('2d'); 168 | ctx.drawImage(video, 0, 0, video.width*2, video.height*2); 169 | this.saveSnapshot(canvas.toDataURL("image/jpeg"), keypressId, counter); 170 | }, 171 | messageForKeypress : function(keypressId, index) { 172 | var messages = this.state.messages; 173 | // console.log('messageForKeypress', keypressId, messages) 174 | for (var i = messages.length - 1; i >= 0; i--) { 175 | var m = messages[i]; 176 | if (m.keypressId == keypressId) { 177 | if (index) {index.i = i;} 178 | return m; 179 | } 180 | } 181 | }, 182 | messageWithIdForKeypress : function(keypressId, cb, retries){ 183 | var message = this.messageForKeypress(keypressId); 184 | retries = retries || 0; 185 | if (!(message && message.snap)) { // we haven't got the id via socket.io 186 | if (retries < 100) { 187 | // console.log("wait for snap id for", keypressId, message, retries) 188 | setTimeout(this.messageWithIdForKeypress.bind(this, 189 | keypressId, cb, retries+1), 100*(retries+1)) 190 | } else { 191 | console.error("too many retries", keypressId, message) 192 | } 193 | } else { // we are good 194 | cb(message) 195 | } 196 | }, 197 | saveLocalSnapshot : function(png, keypressId){ 198 | var messages = this.state.messages; 199 | var findIndex = {} 200 | var existingMessage = this.messageForKeypress(keypressId, findIndex); 201 | if (existingMessage) { 202 | // console.log("update local snapshot", existingMessage) 203 | messages[findIndex.i].snapdata = png; 204 | this.setState({messages : messages}); 205 | } else { 206 | var newMessage = {keypressId : keypressId, snapdata : png} 207 | messages.push(newMessage) 208 | // console.log("save new local snapshot", newMessage) 209 | this.setState({messages : messages}); 210 | } 211 | }, 212 | saveSnapshot : function(png, keypressId, counter){ 213 | // make locally available before saved on server 214 | this.saveLocalSnapshot(png, keypressId) 215 | 216 | // save on server as soon as we have an id 217 | this.messageWithIdForKeypress(keypressId, 218 | function(message){ 219 | // console.log("save pic", message) 220 | var parts = png.split(/[,;:]/), 221 | picId = message.snap.split(":")[0]; 222 | if (counter) { 223 | picId += ":" + counter 224 | } 225 | var postURL = "/snapshot/" + this.props.id + "/" + picId + "/" + keypressId; 226 | if (this.state.selfDestruct) { 227 | postURL+= "?selfDestruct="+this.state.selfDestructTTL; 228 | } 229 | $.ajax({ 230 | type : "POST", 231 | url : postURL, 232 | contentType : parts[1], 233 | data : parts[3], 234 | success : function(data) { 235 | // console.log("saved snap", message) 236 | } 237 | }) 238 | }.bind(this)) 239 | }, 240 | pleasePlayMessage : function(i){ 241 | // console.log("pleasePlayMessage", i) 242 | if (this.state.nowPlaying !== false) { 243 | var rootNode = $(this.getDOMNode()); 244 | // play the audio from the beginning 245 | var audio = rootNode.find("audio")[this.state.nowPlaying] 246 | // console.log("should stop", audio) 247 | audio.load() // fires ended event? 248 | } 249 | this.setState({nowPlaying : false}) 250 | this.playMessage(i) 251 | }, 252 | playMessage : function(i){ 253 | // todo move to message, remove `i` 254 | var message = this.state.messages[i]; 255 | if (!this.state.recording && message) { 256 | if (message.audio) { 257 | var rootNode = $(this.getDOMNode()); 258 | // play the audio from the beginning 259 | 260 | var audio = rootNode.find("audio")[i] 261 | audio.load() 262 | audio.play() 263 | message.played = true 264 | // console.log(audio, audio.ended, audio.networkState) 265 | setTimeout(function() { 266 | // console.log(audio, audio.ended, audio.networkState) 267 | if (audio.networkState != 1) { 268 | this.playFinished(message) 269 | } 270 | }.bind(this),1000) 271 | 272 | this.setState({ 273 | nowPlaying : i, 274 | messages : this.state.messages}) 275 | } else { 276 | this.playMessage(i+1) 277 | } 278 | } else { 279 | this.setState({nowPlaying : false}) 280 | } 281 | }, 282 | playFinished : function(message){ 283 | if (this.state.autoplay) { 284 | // find the index of the current message in the messages array 285 | // by snap. then play the next one 286 | var messages = this.state.messages; 287 | for (var i = messages.length - 1; i >= 0; i--) { 288 | if (messages[i].snap == message.snap) { 289 | break; 290 | } 291 | } 292 | this.playMessage(i + 1) 293 | } else { 294 | this.setState({nowPlaying : false}) 295 | } 296 | }, 297 | setupAudioVideo : function(rootNode, recorder){ 298 | var video = $(rootNode).find("video")[0] 299 | video.muted = true; 300 | video.src = window.URL.createObjectURL(recorder.stream) 301 | }, 302 | autoPlayChanged : function(e){ 303 | $.fn.cookie('autoplay', e.target.checked.toString(), {path : "/"}); 304 | this.setState({autoplay: e.target.checked}) 305 | }, 306 | loadConversation : function(start, end){ 307 | console.log("conversation view", start, end) 308 | var room = this.props.id, 309 | oldMessages = [], min = Math.min(start, end); 310 | for (var i = end; i >= start; i--) { 311 | oldMessages.unshift({ 312 | snap : ["snap",room,i].join("-"), 313 | audio : ["snap",room,i,"audio"].join("-"), 314 | image : true 315 | }) 316 | } 317 | this.setState({messages : oldMessages.concat(this.state.messages)}) 318 | }, 319 | loadEarlierMessages : function(start, end){ 320 | var room = this.props.id, oldest = this.state.messages[0], 321 | before; 322 | if (oldest && oldest.snap) { 323 | before = parseInt(oldest.snap.split('-')[2], 10) 324 | } 325 | var oldMessages = [], min = Math.max(before - 10, 0) 326 | for (var i = before - 1; i >= min; i--) { 327 | oldMessages.unshift({ 328 | snap : ["snap",room,i].join("-"), 329 | audio : ["snap",room,i,"audio"].join("-"), 330 | image : true 331 | }) 332 | } 333 | this.setState({messages : oldMessages.concat(this.state.messages)}) 334 | }, 335 | componentDidMount : function(rootNode){ 336 | if (this.state.start && this.state.end) { 337 | this.loadConversation(this.state.start, this.state.end) 338 | } 339 | connectAudio(function(error, recorder) { 340 | if (error) {return reloadError(error)} 341 | this.setupAudioVideo(rootNode, recorder) 342 | this.listenForSpaceBar() 343 | this.state.socket.emit("join", { 344 | keypressId : this.state.session, 345 | session : this.state.session, 346 | room : this.props.id, 347 | join : true 348 | }) 349 | if (this.state.start && this.state.end && this.state.autoplay) { 350 | this.playMessage(0) 351 | } 352 | setTimeout(function(){ 353 | this.takeSnapshot(this.state.session) 354 | }.bind(this), 1000) 355 | this.setState({recorder: recorder}) 356 | }.bind(this)) 357 | }, 358 | componentDidUpdate : function(oldProps, oldState){ 359 | var el, els = $(".room img.playing") 360 | if (els[0]) { 361 | el = els[0] 362 | } else { 363 | if (oldState.messages[oldState.messages.length-1] && (oldState.messages[oldState.messages.length-1].snap !== 364 | this.state.messages[this.state.messages.length-1].snap)) { 365 | els = $(".room img") 366 | el = els[els.length-1] 367 | } 368 | } // todo check for did the user scroll recently 369 | if (el) {el.scrollIntoView(true)} 370 | }, 371 | selfDestructTTLChanged : function(e) { 372 | $.fn.cookie('selfDestructTTL', e.target.value, {path : "/"}); 373 | this.setState({selfDestructTTL:e.target.value}) 374 | }, 375 | selfDestructChanged : function(e) { 376 | $.fn.cookie('selfDestruct', e.target.checked.toString(), {path : "/"}); 377 | this.setState({selfDestruct:e.target.checked}) 378 | }, 379 | render : function() { 380 | var url = location.origin + "/talk/" + this.props.id; 381 | var recording = this.state.recording ? 382 | Recording. : 383 | ; 384 | var oldestKnownMessage = this.state.messages[0]; 385 | document.title = this.props.id + " - CouchTalk" 386 | var beg = this.state.recorder ? "" :

Smile! ⇑

; 387 | return ( 388 |
389 |
390 | {beg} 391 |
413 | 424 |
425 | ); 426 | } 427 | }) 428 | 429 | 430 | var RecentRooms = React.createClass({ 431 | getInitialState : function(){ 432 | return { 433 | sortedRooms : this.sortedRooms() 434 | } 435 | }, 436 | parseRooms : function(){ 437 | var rooms = $.fn.cookie("rooms"); 438 | if (rooms) { 439 | return JSON.parse(rooms) 440 | } else { 441 | return {} 442 | } 443 | }, 444 | sortedRooms : function() { 445 | var rooms = this.parseRooms() 446 | var sortedRooms = []; 447 | for (var room in rooms) { 448 | if (room !== this.props.id) 449 | sortedRooms.push([room, new Date(rooms[room])]) 450 | } 451 | if (sortedRooms.length > 0) { 452 | sortedRooms.sort(function(a, b) {return b[1] - a[1]}) 453 | // console.log("sortedRooms", sortedRooms) 454 | return sortedRooms; 455 | } 456 | }, 457 | clearHistory : function(){ 458 | $.fn.cookie("rooms", '{}', {path : "/"}) 459 | this.setState({sortedRooms : this.sortedRooms()}) 460 | }, 461 | componentDidMount : function(){ 462 | if (this.props.id) { 463 | var rooms = this.parseRooms() 464 | // console.log("parseRooms", rooms) 465 | rooms[this.props.id] = new Date(); 466 | $.fn.cookie("rooms", JSON.stringify(rooms), {path : "/"}) 467 | } 468 | }, 469 | render : function(){ 470 | if (this.state.sortedRooms) { 471 | return 480 | } else { 481 | return