1
0
mirror of https://github.com/Luzifer/browserphone.git synced 2024-09-16 13:48:35 +00:00

Add call duration, keep notifications

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2022-12-10 21:07:04 +01:00
parent 1a9aae04c3
commit 8fa42200e8
Signed by: luzifer
GPG Key ID: D91C3E91E4CAD6F5
6 changed files with 1927 additions and 80 deletions

150
.eslintrc.js Normal file
View File

@ -0,0 +1,150 @@
/*
* Hack to automatically load globally installed eslint modules
* on Archlinux systems placed in /usr/lib/node_modules
*
* Source: https://github.com/eslint/eslint/issues/11914#issuecomment-569108633
*/
const Module = require('module')
const hacks = [
'babel-eslint',
'eslint-plugin-vue',
]
const ModuleFindPath = Module._findPath
Module._findPath = (request, paths, isMain) => {
const r = ModuleFindPath(request, paths, isMain)
if (!r && hacks.includes(request)) {
return require.resolve(`/usr/lib/node_modules/${request}`)
}
return r
}
/*
* ESLint configuration derived as differences from eslint:recommended
* with changes I found useful to ensure code quality and equal formatting
* https://eslint.org/docs/user-guide/configuring
*/
module.exports = {
env: {
browser: true,
node: true,
},
extends: [
'plugin:vue/recommended',
'eslint:recommended', // https://eslint.org/docs/rules/
],
globals: {
process: true,
},
parserOptions: {
ecmaVersion: 2020,
parser: 'babel-eslint',
},
plugins: [
// required to lint *.vue files
'vue',
],
reportUnusedDisableDirectives: true,
root: true,
rules: {
'array-bracket-newline': ['error', { multiline: true }],
'array-bracket-spacing': ['error'],
'arrow-body-style': ['error', 'as-needed'],
'arrow-parens': ['error', 'as-needed'],
'arrow-spacing': ['error', { after: true, before: true }],
'block-spacing': ['error'],
'brace-style': ['error', '1tbs'],
'comma-dangle': ['error', 'always-multiline'],
'comma-spacing': ['error'],
'comma-style': ['error', 'last'],
'curly': ['error'],
'default-case-last': ['error'],
'default-param-last': ['error'],
'dot-location': ['error', 'property'],
'dot-notation': ['error'],
'eol-last': ['error', 'always'],
'eqeqeq': ['error', 'always', { null: 'ignore' }],
'func-call-spacing': ['error', 'never'],
'function-paren-newline': ['error', 'multiline'],
'generator-star-spacing': ['off'], // allow async-await
'implicit-arrow-linebreak': ['error'],
'indent': ['error', 2],
'key-spacing': ['error', { afterColon: true, beforeColon: false, mode: 'strict' }],
'keyword-spacing': ['error'],
'linebreak-style': ['error', 'unix'],
'lines-between-class-members': ['error'],
'multiline-comment-style': ['warn'],
'newline-per-chained-call': ['error'],
'no-alert': ['error'],
'no-console': ['off'],
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', // allow debugger during development
'no-duplicate-imports': ['error'],
'no-else-return': ['error'],
'no-empty-function': ['error'],
'no-extra-parens': ['error'],
'no-implicit-coercion': ['error'],
'no-lonely-if': ['error'],
'no-multi-spaces': ['error'],
'no-multiple-empty-lines': ['warn', { max: 2, maxBOF: 0, maxEOF: 0 }],
'no-promise-executor-return': ['error'],
'no-return-assign': ['error'],
'no-script-url': ['error'],
'no-template-curly-in-string': ['error'],
'no-trailing-spaces': ['error'],
'no-unneeded-ternary': ['error'],
'no-unreachable-loop': ['error'],
'no-unsafe-optional-chaining': ['error'],
'no-useless-return': ['error'],
'no-var': ['error'],
'no-warning-comments': ['error'],
'no-whitespace-before-property': ['error'],
'object-curly-newline': ['error', { consistent: true }],
'object-curly-spacing': ['error', 'always'],
'object-shorthand': ['error'],
'padded-blocks': ['error', 'never'],
'prefer-arrow-callback': ['error'],
'prefer-const': ['error'],
'prefer-object-spread': ['error'],
'prefer-rest-params': ['error'],
'prefer-template': ['error'],
'quote-props': ['error', 'consistent-as-needed', { keywords: false }],
'quotes': ['error', 'single', { allowTemplateLiterals: true }],
'require-atomic-updates': ['error'],
'require-await': ['error'],
'semi': ['error', 'never'],
'sort-imports': ['error', { ignoreCase: true, ignoreDeclarationSort: false, ignoreMemberSort: false }],
'sort-keys': ['error', 'asc', { caseSensitive: true, natural: false }],
'space-before-blocks': ['error', 'always'],
'space-before-function-paren': ['error', 'never'],
'space-in-parens': ['error', 'never'],
'space-infix-ops': ['error'],
'space-unary-ops': ['error', { nonwords: false, words: true }],
'spaced-comment': ['warn', 'always'],
'switch-colon-spacing': ['error'],
'template-curly-spacing': ['error', 'never'],
'unicode-bom': ['error', 'never'],
'vue/new-line-between-multi-line-property': ['error'],
'vue/no-empty-component-block': ['error'],
'vue/no-reserved-component-names': ['error'],
'vue/no-template-target-blank': ['error'],
'vue/no-unused-properties': ['error'],
'vue/no-unused-refs': ['error'],
'vue/no-useless-mustaches': ['error'],
'vue/order-in-components': ['off'], // Collides with sort-keys
'vue/require-name-property': ['error'],
'vue/v-for-delimiter-style': ['error'],
'vue/v-on-function-call': ['error'],
'wrap-iife': ['error'],
'yoda': ['error'],
},
}

4
Makefile Normal file
View File

@ -0,0 +1,4 @@
default:
lint:
eslint --ext=.js,.vue src/

1588
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,10 @@
"@babel/preset-env": "^7.18.10",
"@babel/runtime": "^7.18.9",
"babel-loader": "^8.2.5",
"babel-eslint": "^10.1.0",
"css-loader": "^6.7.1",
"eslint": "^7.19.0",
"eslint-plugin-vue": "^7.20.0",
"node-sass": "^7.0.1",
"sass-loader": "^13.0.2",
"style-loader": "^3.3.1",

View File

@ -1,49 +1,99 @@
<template>
<div id="app">
<b-navbar toggleable="lg" type="dark" variant="primary">
<b-navbar-brand href="#">BrowserPhone</b-navbar-brand>
<b-navbar
toggleable="lg"
type="dark"
variant="primary"
>
<b-navbar-brand href="#">
BrowserPhone
</b-navbar-brand>
<b-navbar-toggle target="nav-collapse"></b-navbar-toggle>
<b-navbar-toggle target="nav-collapse" />
<b-collapse id="nav-collapse" is-nav>
<b-collapse
id="nav-collapse"
is-nav
>
<b-navbar-nav class="ml-auto">
<b-nav-text v-if="identity">Signed in as <strong>{{ identity }}</strong></b-nav-text>
<b-nav-text v-if="identity">
Signed in as <strong>{{ identity }}</strong>
</b-nav-text>
<b-nav-item @click="setupPhone">
Reload
</b-nav-item>
</b-navbar-nav>
</b-collapse>
</b-navbar>
<b-container>
<b-row class="justify-content-center mt-5">
<b-col cols="12" md="6">
<b-col
cols="12"
md="6"
>
<b-card>
<b-form-input
autofocus
class="my-2 number border-info"
@keypress="evt => keypress(evt)"
size="lg"
slot="header"
type="tel"
v-model="number"
></b-form-input>
<template v-if="phone.started">
<b-form-input
slot="header"
v-model="number"
autofocus
class="my-2 number border-info"
size="lg"
type="tel"
@keypress="evt => keypress(evt)"
/>
<b-row class="justify-content-center">
<b-col v-for="key in keys" cols="4" class="text-center mb-3" :key="key">
<b-btn size="lg" @click="keyDown(key)">{{ key }}</b-btn>
</b-col>
<b-row class="justify-content-center">
<b-col
v-for="key in keys"
:key="key"
cols="4"
class="text-center mb-3"
>
<b-btn
size="lg"
@click="keyDown(key)"
>
{{ key }}
</b-btn>
</b-col>
<b-col cols="4" class="text-center mb-2">
<b-btn size="lg" :variant="ongoingCall ? 'danger' : 'success'" @click="toggleCall" :disabled="!phoneReady">
<i :class="{ 'fas': true, 'fa-phone': !ongoingCall, 'fa-phone-slash': ongoingCall }"></i>
</b-btn>
</b-col>
</b-row>
<b-col
cols="4"
class="text-center mb-2"
>
<b-btn
size="lg"
:variant="ongoingCall ? 'danger' : 'success'"
:disabled="!phoneReady"
@click="toggleCall"
>
<i :class="{ 'fas': true, 'fa-phone': !ongoingCall, 'fa-phone-slash': ongoingCall }" />
</b-btn>
</b-col>
</b-row>
</template>
<div slot="footer" v-if="state">
{{ state }}
<template v-else>
<div class="d-flex justify-content-center">
<b-button
variant="primary"
@click="setupPhone"
>
Initialize
</b-button>
</div>
</template>
<div
slot="footer"
class="d-flex justify-content-between"
>
<span><template v-if="state.time">{{ stateDate }}</template> <template v-if="state.msg">{{ state.msg }}</template></span>
<span>{{ callDuration }}</span>
</div>
</b-card>
</b-col>
</b-row>
</b-container>
@ -52,14 +102,27 @@
<script>
import axios from 'axios'
import config from './config.js'
import { Device } from '@twilio/voice-sdk'
import config from './config.js'
export default {
name: 'app',
computed: {
callDuration() {
if (!this.phone.callConnected) {
return '--:--'
}
let secs = Math.round((this.now - this.phone.callConnected) / 1000)
const parts = []
for (const div of [3600, 60, 1]) {
const n = Math.floor(secs / div)
secs -= n * div
parts.push(n.toFixed(0).padStart(2, '0'))
}
return parts.join(':')
},
keys() {
return ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#']
},
@ -73,7 +136,20 @@ export default {
},
phoneReady() {
return this.phone.conn && this.phone.device && this.phone.registered
return this.phone.device && this.phone.registered
},
stateDate() {
if (!this.state.time) {
return null
}
return [
this.state.time.getHours().toFixed(0)
.padStart(2, '0'),
this.state.time.getMinutes().toFixed(0)
.padStart(2, '0'),
].join(':')
},
},
@ -85,14 +161,23 @@ export default {
max: 30000,
multiplier: 1.25,
},
identity: null,
now: new Date(),
number: '',
phone: {
callConnected: null,
conn: null,
device: null,
registered: false,
started: false,
},
state: '',
state: {
msg: '',
time: null,
},
wakeLock: {
obj: null,
request: null,
@ -101,12 +186,14 @@ export default {
},
methods: {
announceStatus(state, autoClear = true) {
this.state = state
announceStatus(state, autoClear = false) {
this.state.msg = state
this.state.time = new Date()
if (autoClear) {
window.setTimeout(() => {
this.state = ''
this.state.msg = ''
this.state.time = null
}, 2000)
}
},
@ -116,15 +203,21 @@ export default {
this.phone.conn.on('accept', conn => {
this.phone.conn = conn
this.phone.callConnected = new Date()
this.announceStatus('Call connected')
this.setWakeLock(true)
})
this.phone.conn.on('disconnect', () => {
this.phone.conn = null
this.phone.callConnected = null
this.announceStatus('Call disconnected')
this.setWakeLock(false)
})
this.phone.conn.on('error', err => {
console.error('error in call', err)
})
},
keyDown(key) {
@ -142,46 +235,6 @@ export default {
}
},
setupPhone() {
this.backoff.current = Math.min(this.backoff.current * this.backoff.multiplier, this.backoff.max)
const opts = { codecPreferences: ['opus', 'pcmu'], fakeLocalDTMF: true }
if (this.phone.device) {
this.phone.device.destroy()
}
axios.get(config.capabilityTokenURL, {
headers: {
authorization: `Bearer ${config.capabilityTokenAuth}`,
},
})
.then(resp => {
this.phone.device = new Device(resp.data.token, opts)
this.phone.device.on('registered', () => {
this.backoff.current = this.backoff.initial
this.phone.registered = true
this.announceStatus('Device registered')
})
this.phone.device.on('error', err => console.error(err))
this.phone.device.on('incoming', call => {
this.announceStatus(`Incoming call from ${call.parameters.From}...`, false)
this.handleCall(call)
})
this.phone.device.on('unregistered', () => {
this.phone.registered = false
this.announceStatus('Phone unregistered, reconnecting...')
window.setTimeout(() => this.setupPhone(), this.backoff.current)
})
this.phone.device.register()
})
.catch(err => console.error(err))
},
async setWakeLock(lock) {
if (!navigator || !navigator.getWakeLock) {
// No wake-lock functionality present in this browser
@ -206,6 +259,50 @@ export default {
}
},
setupPhone() {
this.backoff.current = Math.min(this.backoff.current * this.backoff.multiplier, this.backoff.max)
const opts = {
codecPreferences: ['opus', 'pcmu'],
fakeLocalDTMF: true,
}
if (this.phone.device) {
this.phone.device.destroy()
}
axios.get(config.capabilityTokenURL, {
headers: {
authorization: `Bearer ${config.capabilityTokenAuth}`,
},
})
.then(resp => {
this.phone.device = new Device(resp.data.token, opts)
this.phone.device.on('registered', () => {
this.backoff.current = this.backoff.initial
this.phone.registered = true
this.phone.started = true
this.announceStatus('Device registered')
})
this.phone.device.on('error', err => console.error(err))
this.phone.device.on('incoming', call => {
this.announceStatus(`Incoming call from ${call.parameters.From}...`, false)
this.handleCall(call)
})
this.phone.device.on('unregistered', () => {
this.phone.registered = false
this.announceStatus('Phone unregistered, reconnecting...')
window.setTimeout(() => this.setupPhone(), this.backoff.current)
})
this.phone.device.register()
})
.catch(err => console.error(err))
},
toggleCall() {
if (this.pendingCall) {
this.phone.conn.accept()
@ -232,10 +329,13 @@ export default {
},
mounted() {
window.setInterval(() => {
this.now = new Date()
}, 250)
this.announceStatus('Phone loaded...')
this.setupPhone()
},
name: 'BrowserPhoneApp',
}
</script>

View File

@ -1,3 +1,4 @@
/* eslint-disable sort-imports */
import Vue from 'vue'
import BootstrapVue from 'bootstrap-vue'
@ -10,8 +11,9 @@ import app from './app.vue'
Vue.use(BootstrapVue)
new Vue({
window.app = new Vue({
components: { app },
el: '#app',
name: 'BrowserPhone',
render: c => c('app'),
})