WIP:playback queue and tray icon states
This commit is contained in:
parent
c52ea2bcd2
commit
a4d6a883b0
37 changed files with 365 additions and 470 deletions
|
|
@ -114,8 +114,6 @@ export const updateAppState = () => {
|
|||
|
||||
ipcEmit("set-version", app.getVersion());
|
||||
|
||||
updateAppUI();
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
139
src/audio.ts
139
src/audio.ts
|
|
@ -1,5 +1,8 @@
|
|||
const nodeAudio = require('@crimata/nodeaudio');
|
||||
import { globalShortcut } from "electron";
|
||||
const nodeAudio = require('@crimata/nodeaudio');
|
||||
|
||||
import { updateTray } from "@/tray";
|
||||
import { sendMessage } from "@/session";
|
||||
import { backgroundMitt, ipcEmit } from '@/composables/useEmitter';
|
||||
|
||||
let inputDevice: number;
|
||||
|
|
@ -7,68 +10,100 @@ let outputDevice: number;
|
|||
|
||||
let setStreamsId: ReturnType<typeof setTimeout>;
|
||||
|
||||
let playbackEl;
|
||||
let writeToOBuffer = false;
|
||||
let recording = false;
|
||||
const chunks: Int16Array[] = [];
|
||||
|
||||
// backgroundMitt.on("data", (int16Arr: Int16Array) => {
|
||||
// if (recording) {
|
||||
// chunks.push(int16Arr);
|
||||
// ipcEmit("recording", int16Arr); // for recording animation
|
||||
// }
|
||||
// });
|
||||
backgroundMitt.on("data", (int16Arr: Int16Array) => {
|
||||
if (recording) {
|
||||
chunks.push(int16Arr);
|
||||
ipcEmit("recording", int16Arr);
|
||||
}
|
||||
});
|
||||
|
||||
// const setStreams = () => {
|
||||
backgroundMitt.on("playback", (uid: string, aplitude: number) => {
|
||||
ipcEmit("animate-playback", { uid: uid, aplitude: aplitude });
|
||||
});
|
||||
|
||||
// const defaultInput = nodeAudio.GetDefaultInputDevice();
|
||||
// const defaultOutput = nodeAudio.GetDefaultOutputDevice();
|
||||
backgroundMitt.on("write", (status: boolean) => {
|
||||
writeToOBuffer = status;
|
||||
});
|
||||
|
||||
// if (inputDevice !== defaultInput) {
|
||||
// inputDevice = defaultInput;
|
||||
// nodeAudio.CloseInputStream(inputDevice);
|
||||
// nodeAudio.OpenInputStream(inputDevice);
|
||||
// }
|
||||
const setStreams = () => {
|
||||
|
||||
// if (outputDevice !== defaultOutput) {
|
||||
// outputDevice = defaultOutput;
|
||||
// nodeAudio.CloseOutputStream(outputDevice);
|
||||
// nodeAudio.OpenOutputStream(outputDevice);
|
||||
// }
|
||||
// }
|
||||
const defaultInput = nodeAudio.core.GetDefaultInputDevice();
|
||||
const defaultOutput = nodeAudio.core.GetDefaultOutputDevice();
|
||||
|
||||
// const startRecording = () => {
|
||||
// recording = true;
|
||||
// }
|
||||
|
||||
// const stopRecording = () => {
|
||||
// recording = false;
|
||||
// const audio: Int16Array = nodeAudio.MergeChunks(chunks);
|
||||
// backgroundMitt.emit("audio", audio); // emit audio event
|
||||
// chunks.length = 0;
|
||||
// }
|
||||
|
||||
export const initAudio = () => {
|
||||
// nodeAudio.Initialize(backgroundMitt.emit.bind(backgroundMitt));
|
||||
// setStreamsId = setInterval(setStreams, 2000); // sense device
|
||||
|
||||
const ret = globalShortcut.register('CommandOrControl+R', () => {
|
||||
console.log('Toggle record...');
|
||||
});
|
||||
|
||||
if (!ret) {
|
||||
console.log('registration failed');
|
||||
if (inputDevice !== defaultInput) {
|
||||
inputDevice = defaultInput;
|
||||
nodeAudio.core.CloseInputStream(inputDevice);
|
||||
nodeAudio.core.OpenInputStream(inputDevice);
|
||||
}
|
||||
|
||||
// Check whether a shortcut is registered.
|
||||
console.log(globalShortcut.isRegistered('CommandOrControl+R'));
|
||||
if (outputDevice !== defaultOutput) {
|
||||
outputDevice = defaultOutput;
|
||||
nodeAudio.core.CloseOutputStream(outputDevice);
|
||||
nodeAudio.core.OpenOutputStream(outputDevice);
|
||||
}
|
||||
}
|
||||
|
||||
const startRecording = () => {
|
||||
|
||||
/* If playback is happening, we kill it */
|
||||
if (playback) nodeAudio.core.ClearOutputBuffer();
|
||||
|
||||
recording = true;
|
||||
updateTray("recording", recording);
|
||||
}
|
||||
|
||||
const stopRecording = () => {
|
||||
recording = false;
|
||||
updateTray("recording", recording);
|
||||
|
||||
/* Send the data to the platform right away */
|
||||
sendMessage({
|
||||
text: false,
|
||||
audio: nodeAudio.utils.mergeChunks(chunks)
|
||||
} as Raw);
|
||||
|
||||
chunks.length = 0;
|
||||
}
|
||||
|
||||
export const initAudio = () => {
|
||||
nodeAudio.core.Initialize(backgroundMitt.emit.bind(backgroundMitt));
|
||||
setStreamsId = setInterval(setStreams, 2000); // sense device
|
||||
|
||||
/* Toggle recording switch with global shortcut */
|
||||
globalShortcut.register('CommandOrControl+R', () => {
|
||||
if (recording) {
|
||||
stopRecording();
|
||||
} else {
|
||||
startRecording();
|
||||
};
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
// export const playback = (audio: Int16Array) => {
|
||||
// console.log("Playing audio");
|
||||
// // nodeAudio.WriteToOutputStream(audio);
|
||||
// }
|
||||
export const playback = (audio: Int16Array, source: string) => {
|
||||
|
||||
// export const terminateAudio = () => {
|
||||
// console.log("Terminating audio");
|
||||
// // clearInterval(setStreamsId);
|
||||
// }
|
||||
/* If existing playback is happening, we kill it */
|
||||
if (playback) nodeAudio.core.ClearOutputBuffer();
|
||||
|
||||
const playbackEl = uid;
|
||||
|
||||
nodeAudio.core.WriteToOutputStream(audio.buffer);
|
||||
}
|
||||
|
||||
export const terminateAudio = () => {
|
||||
clearInterval(setStreamsId);
|
||||
nodeAudio.core.Terminate();
|
||||
}
|
||||
|
||||
export const getStreamState = () => {
|
||||
let busy = false;
|
||||
if (playback || recording) {
|
||||
busy = true;
|
||||
}
|
||||
return busy;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import createWindow from "./window";
|
|||
import main from "./main";
|
||||
import { backgroundMitt } from '@/composables/useEmitter';
|
||||
import { setWindowOpen, getWindowOpen } from './store';
|
||||
import { terminateAudio } from './audio';
|
||||
|
||||
console.log('Starting Crimata electron app.');
|
||||
|
||||
|
|
@ -31,6 +32,7 @@ app.on("ready", async () => {
|
|||
});
|
||||
|
||||
app.on("will-quit", () => {
|
||||
terminateAudio();
|
||||
globalShortcut.unregisterAll();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { IpcListener } from "@/composables/useIpcMain"
|
||||
import { sendMessage } from '@/session';
|
||||
import { updateAppState } from "@/account";
|
||||
import { playback } from "@/audio";
|
||||
import { onNavBar } from "@/window";
|
||||
import { sendMessage } from '@/session';
|
||||
import { setWindowFocus } from '@/store';
|
||||
import { updateAppState } from "@/account";
|
||||
import { IpcListener } from "@/composables/useIpcMain"
|
||||
|
||||
const CLIENT_MESSAGE_CHANNEL = "post-session-send"
|
||||
const APP_MOUNT_CHANNEL = "post-app-mount";
|
||||
const NAV_BAR_CHANNEL = "post-nav-bar";
|
||||
const POST_WINDOW_FOCUS = 'post-window-focus';
|
||||
const PLAYBACK_CHANNEL = "post-playback;"
|
||||
|
||||
export const messageListener = new IpcListener<Raw | Request>({
|
||||
channel: CLIENT_MESSAGE_CHANNEL,
|
||||
|
|
@ -28,3 +30,8 @@ export const windowFocusListener = new IpcListener<FocusPayload>({
|
|||
channel: POST_WINDOW_FOCUS,
|
||||
listenerCallback: ({ isFocused }) => setWindowFocus(isFocused)
|
||||
});
|
||||
|
||||
export const playbackListener = new IpcListener({
|
||||
channel: PLAYBACK_CHANNEL,
|
||||
listenerCallback: playback
|
||||
});
|
||||
|
|
|
|||
17
src/main.ts
17
src/main.ts
|
|
@ -1,18 +1,7 @@
|
|||
/**
|
||||
* Where the background logic really begins, gets called by app.onReady().
|
||||
*
|
||||
* Handles authentication. If profile is set, we launch a session, which consis
|
||||
* of opening a connection with the platform, initializing the audio streams.
|
||||
*
|
||||
* The session is primarily an interface between the frontend and the platform,
|
||||
* relaying messages from one to the other.
|
||||
*
|
||||
*/
|
||||
|
||||
import { initTray } from '@/tray';
|
||||
import createWindow from "@/window";
|
||||
import initIpcMain from "@/ipc/index";
|
||||
import { accountAuth } from "./account";
|
||||
import createWindow from "./window";
|
||||
import { initTray } from './tray';
|
||||
import { accountAuth } from "@/account";
|
||||
|
||||
export default async function main() {
|
||||
|
||||
|
|
|
|||
81
src/render/audioVisualizer.js
Normal file
81
src/render/audioVisualizer.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
class AudioVisualizer {
|
||||
constructor( audioContext, processFrame, processError ) {
|
||||
this.audioContext = audioContext;
|
||||
this.processFrame = processFrame;
|
||||
this.connectStream = this.connectStream.bind( this );
|
||||
navigator.mediaDevices.getUserMedia( { audio: true, video: false } )
|
||||
.then( this.connectStream )
|
||||
.catch( ( error ) => {
|
||||
if ( processError ) {
|
||||
processError( error );
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
connectStream( stream ) {
|
||||
this.analyser = this.audioContext.createAnalyser();
|
||||
const source = this.audioContext.createMediaStreamSource( stream );
|
||||
source.connect( this.analyser );
|
||||
this.analyser.smoothingTimeConstant = 0.5;
|
||||
this.analyser.fftSize = 32;
|
||||
|
||||
this.initRenderLoop( this.analyser );
|
||||
}
|
||||
|
||||
initRenderLoop() {
|
||||
const frequencyData = new Uint8Array( this.analyser.frequencyBinCount );
|
||||
const processFrame = this.processFrame || ( () => {} );
|
||||
|
||||
const renderFrame = () => {
|
||||
this.analyser.getByteFrequencyData( frequencyData );
|
||||
processFrame( frequencyData );
|
||||
|
||||
requestAnimationFrame( renderFrame );
|
||||
};
|
||||
requestAnimationFrame( renderFrame );
|
||||
}
|
||||
}
|
||||
|
||||
const visualMainElement = document.querySelector( 'main' );
|
||||
const visualValueCount = 16;
|
||||
let visualElements;
|
||||
const createDOMElements = () => {
|
||||
let i;
|
||||
for ( i = 0; i < visualValueCount; ++i ) {
|
||||
const elm = document.createElement( 'div' );
|
||||
visualMainElement.appendChild( elm );
|
||||
}
|
||||
|
||||
visualElements = document.querySelectorAll( 'main div' );
|
||||
};
|
||||
createDOMElements();
|
||||
|
||||
const init = () => {
|
||||
// Creating initial DOM elements
|
||||
const audioContext = new AudioContext();
|
||||
const initDOM = () => {
|
||||
visualMainElement.innerHTML = '';
|
||||
createDOMElements();
|
||||
};
|
||||
initDOM();
|
||||
|
||||
// Swapping values around for a better visual effect
|
||||
const dataMap = { 0: 15, 1: 10, 2: 8, 3: 9, 4: 6, 5: 5, 6: 2, 7: 1, 8: 0, 9: 4, 10: 3, 11: 7, 12: 11, 13: 12, 14: 13, 15: 14 };
|
||||
const processFrame = ( data ) => {
|
||||
const values = Object.values( data );
|
||||
let i;
|
||||
for ( i = 0; i < visualValueCount; ++i ) {
|
||||
const value = values[ dataMap[ i ] ] / 255;
|
||||
const elmStyles = visualElements[ i ].style;
|
||||
elmStyles.transform = `scaleY( ${ value } )`;
|
||||
elmStyles.opacity = Math.max( .25, value );
|
||||
}
|
||||
};
|
||||
|
||||
const processError = () => {
|
||||
visualMainElement.classList.add( 'error' );
|
||||
visualMainElement.innerText = 'Please allow access to your microphone in order to see this demo.\nNothing bad is going to happen... hopefully :P';
|
||||
}
|
||||
|
||||
const a = new AudioVisualizer( audioContext, processFrame, processError );
|
||||
};
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
<span :class="`context ${modifier}-context`">
|
||||
|
||||
<div v-if="context.img" class="avatar">
|
||||
<img v-if="context.img === 'crimata'" :src="require('@/render/assets/crimataAvatar.svg')">
|
||||
<div v-if="context.img" class="avatar" @click="clickMethod">
|
||||
<img v-click="" v-if="context.img === 'crimata'" :src="require('@/render/assets/crimataAvatar.svg')">
|
||||
<div v-else>{{ context.img }}</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,14 @@ export function animateAudioInput () {
|
|||
|
||||
}
|
||||
|
||||
/* TODO: animation function that takes in a message ID, and data about the
|
||||
* playedback audio. We target the element with the UID and animate it according
|
||||
* to the data. - still very hypothetical...
|
||||
*/
|
||||
export function animatePlayback (uid: string, meta: number) {
|
||||
console.log("animating playback!")
|
||||
}
|
||||
|
||||
// Given index and length of content, return message child status.
|
||||
export function calcChild (index: number, len: number) {
|
||||
if (len === 1) {
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
import { onMounted, onUnmounted, ref, Ref } from "vue";
|
||||
import { postMessage } from "@/render/ipc";
|
||||
import { animateAudioInput } from "./helpers";
|
||||
import { invokeReturnAudio, postAudioChunk } from "@/render/ipc";
|
||||
|
||||
export default function useAudioInputController (typing: Ref) {
|
||||
|
||||
const recording = ref(false);
|
||||
let mediaRecorder: MediaRecorder;
|
||||
|
||||
const { show, hide } = animateAudioInput();
|
||||
|
||||
// initialize audio
|
||||
const conf = {audio: true, video: false}
|
||||
navigator.mediaDevices.getUserMedia(conf).then((stream: MediaStream) => {
|
||||
|
||||
const options = {mimeType: 'audio/webm'};
|
||||
mediaRecorder = new MediaRecorder(stream, options);
|
||||
|
||||
// post any mew audio to backend
|
||||
mediaRecorder.addEventListener('dataavailable', (e: BlobEvent) => {
|
||||
e.data.arrayBuffer().then((buff: ArrayBuffer) => {
|
||||
postAudioChunk(buff);
|
||||
});
|
||||
});
|
||||
|
||||
// get audio and post new message to backend
|
||||
mediaRecorder.addEventListener('stop', (_e: Event) => {
|
||||
invokeReturnAudio().then((audio: ArrayBuffer[] | Error) => {
|
||||
console.log(audio);
|
||||
// postMessage(newMessage({audio: audio}));
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// start recording on space bar
|
||||
const record = () => {
|
||||
console.log("INPT:Capturing audio...")
|
||||
mediaRecorder.start();
|
||||
recording.value = true;
|
||||
show()
|
||||
}
|
||||
|
||||
// stop recording and send on release
|
||||
const stop = () => {
|
||||
console.log("INPT:Stopping record.")
|
||||
mediaRecorder.stop();
|
||||
recording.value = false;
|
||||
hide();
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.keyCode == 32 && !typing.value) record();
|
||||
}
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.keyCode == 32 && recording.value) stop();
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keyup", onKeyUp);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("keyup", onKeyUp);
|
||||
})
|
||||
|
||||
return {
|
||||
recording
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -19,21 +19,19 @@
|
|||
type="text"
|
||||
/>
|
||||
|
||||
<!-- Show suggestions menu on click -->
|
||||
<!-- <Menu /> -->
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
import { defineComponent } from "vue";
|
||||
import { defineComponent, watch } from "vue";
|
||||
import draggify from "@/render/composables/useDraggify";
|
||||
import { profile } from "@/render/shared/profile";
|
||||
import { recording } from "@/render/shared/audio";
|
||||
import { animateAudioInput } from "@/render/components/controllers/helpers";
|
||||
|
||||
import useTextInputController from
|
||||
"@/render/components/controllers/inputItem.control.text";
|
||||
// import useAudioInputController from
|
||||
"@/render/components/controllers/inputItem.control.audio";
|
||||
|
||||
export default defineComponent({
|
||||
|
|
@ -50,8 +48,12 @@ export default defineComponent({
|
|||
|
||||
// Controllers for text and audio.
|
||||
const { typing } = useTextInputController(elementX)
|
||||
// const { recording } = useAudioInputController(typing)
|
||||
const recording = false;
|
||||
|
||||
// Control rec icon (red dot upper right of UI)
|
||||
const { show, hide } = animateAudioInput();
|
||||
watch(recording, (val, _oldval) => {
|
||||
recording ? show() : hide();
|
||||
});
|
||||
|
||||
return {
|
||||
profile,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ const { post, invoke } = useIpc();
|
|||
*
|
||||
*/
|
||||
|
||||
|
||||
export const invokeLogin = async (
|
||||
payload: AccountCredentials
|
||||
): Promise<Profile | Error> => (
|
||||
|
|
@ -27,12 +26,8 @@ export const invokeLogout = async (): Promise<void> => (
|
|||
*
|
||||
*/
|
||||
|
||||
export const postAudioChunk = (chunk: ArrayBuffer): void => (
|
||||
post("post-audio-collect", chunk)
|
||||
);
|
||||
|
||||
export const invokeReturnAudio = async (): Promise<ArrayBuffer[] | Error> => (
|
||||
await invoke("invoke-audio-flush", null)
|
||||
export const postPlayback = (payload: string): void => (
|
||||
post('post-playback', payload)
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { setAccount } from "./shared/account";
|
|||
import { setProfile } from "./shared/profile";
|
||||
import { setVersion } from "./shared/version";
|
||||
import { setConnectionStatus } from "./shared/connectionStatus";
|
||||
import { setRecording } from "./shared/audio";
|
||||
|
||||
const SET_ACCOUNT_CHANNEL = "set-account";
|
||||
const SET_PROFILE_CHANNEL = "set-profile";
|
||||
|
|
@ -23,6 +24,7 @@ const ADD_MESSAGE_CHANNEL = "add-message";
|
|||
const UPDATE_MESSAGE_CHANNEL = "update-message";
|
||||
const DELETE_MESSAGE_CHANNEL = "delete-message";
|
||||
const CONNECTION_STATUS_CHANNEL = "set-connection-status";
|
||||
const RECORDING_STATUS_CHANNEL = "recording-status";
|
||||
|
||||
export const setAccountListener = new IpcRendererListener({
|
||||
channel: SET_ACCOUNT_CHANNEL,
|
||||
|
|
@ -63,4 +65,14 @@ export const deleteMessagesListener = new IpcRendererListener({
|
|||
export const connectionStatusListener = new IpcRendererListener({
|
||||
channel: CONNECTION_STATUS_CHANNEL,
|
||||
listenerCallback: setConnectionStatus
|
||||
});
|
||||
|
||||
export const recordingStatusListener = new IpcRendererListener({
|
||||
channel: RECORDING_STATUS_CHANNEL,
|
||||
listenerCallback: setRecording
|
||||
});
|
||||
|
||||
export const animatePlaybackListener = new IpcRendererListener({
|
||||
channel: PLAYBACK_LISTENER,
|
||||
listenerCallback: animatePlayback
|
||||
});
|
||||
12
src/render/shared/audio.ts
Normal file
12
src/render/shared/audio.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// shared
|
||||
import { ref } from "vue";
|
||||
|
||||
export const recording = ref();
|
||||
|
||||
export const setRecording: IpcListenerCallback<boolean> = (payload) => {
|
||||
recording.value = payload;
|
||||
}
|
||||
|
||||
export default {
|
||||
recording
|
||||
}
|
||||
145
src/session.ts
145
src/session.ts
|
|
@ -1,117 +1,112 @@
|
|||
import { Notification } from 'electron';
|
||||
|
||||
// import useAudio from "@/audio";
|
||||
import UIState from "@/uiState";
|
||||
import { config } from "@/config";
|
||||
import useWebsockets from "./composables/useWebsockets";
|
||||
import { updateTray } from "@/tray";
|
||||
import { getWindowFocus, getWindowOpen } from '@/store';
|
||||
import useWebsockets from "@/composables/useWebsockets";
|
||||
import { backgroundMitt, ipcEmit } from "@/composables/useEmitter";
|
||||
import { Notification } from 'electron';
|
||||
import { getWindowFocus, getWindowOpen } from './store';
|
||||
import { initAudio, terminateAudio, playback } from "./audio";
|
||||
import { initAudio, terminateAudio, playback, getStreamState } from "@/audio";
|
||||
|
||||
export const CONNECTION_STATUS = {
|
||||
connected: 'Connected',
|
||||
nominal: 'Nominal',
|
||||
reconnecting: 'Reconnecting',
|
||||
lost: 'Connection Lost',
|
||||
};
|
||||
const contentQueue: Update[] = [];
|
||||
|
||||
const uiState = new UIState();
|
||||
|
||||
const onMessageCallback = (payload: string) => {
|
||||
const onMessageCallback = async (payload: string) =>
|
||||
{
|
||||
const message = JSON.parse(payload) as ClientProtocol;
|
||||
|
||||
if (payload === "CLOSE_AUTH_FAIL") {
|
||||
backgroundMitt.emit("close-auth-fail");
|
||||
if (message.header == "init") {
|
||||
uiState.set(message.body as Init);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = JSON.parse(payload) as ClientProtocol;
|
||||
|
||||
if (message.header === "init")
|
||||
if (content)
|
||||
{
|
||||
uiState.set(message.body as Init);
|
||||
contentQueue.push(message.body)
|
||||
}
|
||||
else
|
||||
{
|
||||
const body = message.body as Update;
|
||||
uiState.update(body);
|
||||
|
||||
if (body.name === "add")
|
||||
{
|
||||
const data = body.data as Message;
|
||||
|
||||
// Show a notification if new content and window is not focused.
|
||||
if (!getWindowFocus())
|
||||
{
|
||||
new Notification({
|
||||
title: 'New Message',
|
||||
subtitle: data.context.text as string,
|
||||
body: data.content[0].text,
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
// Playback audio if message has audio.
|
||||
// TODO: Should we worry about playback and record at the same time?
|
||||
// if (data.content.audio)
|
||||
// {
|
||||
// playback(data.content.audio);
|
||||
// }
|
||||
}
|
||||
uiState.update(message.body as Update);
|
||||
}
|
||||
}
|
||||
|
||||
const statusOptions = { openMessage: 'Connected',
|
||||
pongMessage: 'Nominal',
|
||||
closeMessage: 'Reconnecting',
|
||||
pingErrorMessage: 'Connection Lost' };
|
||||
|
||||
const connectionStatusCallback = (status: string) =>
|
||||
{
|
||||
/* Simplify connectionStatus for tray state */
|
||||
let connectionStatusSimple = false;
|
||||
if (status == statusOptions.pingErrorMessage || statusOptions.closeMessage)
|
||||
{
|
||||
connectionStatusSimple = true;
|
||||
}
|
||||
|
||||
updateTray("disconnect", connectionStatusSimple);
|
||||
|
||||
const connectionStatusCallback = (status: string) => {
|
||||
// backgroundMitt.emit("update-icon-status", status);
|
||||
ipcEmit('set-connection-status', status);
|
||||
}
|
||||
|
||||
const statusOptions = {
|
||||
openMessage: CONNECTION_STATUS.connected,
|
||||
pongMessage: CONNECTION_STATUS.nominal,
|
||||
closeMessage: CONNECTION_STATUS.reconnecting,
|
||||
pingErrorMessage: CONNECTION_STATUS.lost,
|
||||
}
|
||||
const { connect, send, close } = useWebsockets(onMessageCallback,
|
||||
connectionStatusCallback, statusOptions);
|
||||
|
||||
const { connect, send, close } = useWebsockets(
|
||||
onMessageCallback,
|
||||
connectionStatusCallback,
|
||||
statusOptions
|
||||
);
|
||||
|
||||
export const updateAppUI = (): void => {
|
||||
uiState.emit();
|
||||
}
|
||||
|
||||
// Listen for audio recordings and send them to backend.
|
||||
backgroundMitt.on("audio", (audio: Int16Array) => {
|
||||
sendMessage({
|
||||
text: false,
|
||||
audio: audio
|
||||
} as Raw);
|
||||
});
|
||||
|
||||
export function sendMessage(message: Raw | Request): void {
|
||||
export function sendMessage(message: Raw | Request): void
|
||||
{
|
||||
send(message);
|
||||
}
|
||||
|
||||
/* launch a new session (the main process for authenticated users) */
|
||||
export function launchSession(platformKey: string) {
|
||||
let processContentId: ReturnType<typeof setTimeout>;
|
||||
|
||||
/* launch a new session (the main process for authenticated users) */
|
||||
export function launchSession(platformKey: string)
|
||||
{
|
||||
/* connect to the platform */
|
||||
connect(config.PLATFORM_URL, platformKey);
|
||||
|
||||
initAudio();
|
||||
|
||||
processContentId = setInterval(() =>
|
||||
{
|
||||
if (getStreamState())
|
||||
{
|
||||
if (contentQueue.length > 0)
|
||||
{
|
||||
const update = contentQueue.shift() as Update;
|
||||
|
||||
/* Update the UI */
|
||||
uiState.update(update);
|
||||
|
||||
/* Spawn notification if window not in focus */
|
||||
if (!getWindowFocus())
|
||||
{
|
||||
new Notification({
|
||||
title: "New Message",
|
||||
subtitle: data.context.text,
|
||||
body: data.content[0].text
|
||||
}).show();
|
||||
}
|
||||
|
||||
/* Play audio if audio */
|
||||
if (update.data.content[-1].audio)
|
||||
{
|
||||
playback(message.audio, message.uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
export function endSession() {
|
||||
|
||||
export function endSession()
|
||||
{
|
||||
terminateAudio();
|
||||
|
||||
close(1000, 'session-logout');
|
||||
|
||||
uiState.reset();
|
||||
clearInterval(processContentId)
|
||||
|
||||
uiState.reset();
|
||||
}
|
||||
|
||||
|
|
|
|||
94
src/tray.ts
94
src/tray.ts
|
|
@ -1,90 +1,34 @@
|
|||
|
||||
"use strict";
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { Tray } from 'electron';
|
||||
const nativeImage = require('electron').nativeImage;
|
||||
import { NativeImage } from 'electron'
|
||||
import { backgroundMitt } from "@/composables/useEmitter";
|
||||
import { getIconState, setIconState } from "@/store";
|
||||
import { CONNECTION_STATUS } from '@/session';
|
||||
|
||||
declare const __static: string;
|
||||
let tray: Tray;
|
||||
const ICON_PATH = "./build/tray";
|
||||
|
||||
interface Icons {
|
||||
[name: string]: NativeImage;
|
||||
/* Current state of recording, playback, and ws connection */
|
||||
const state = {
|
||||
recording: false,
|
||||
playback: false,
|
||||
disconnect: false // whether we're experiencing connection issues
|
||||
}
|
||||
|
||||
let tray: Tray | null = null;
|
||||
const TRAY_ICON_DIR = 'trayIcon';
|
||||
export function updateTray(attribute: "recording" | "playback" | "disconnect", newState: boolean) {
|
||||
|
||||
const ICON_NAMES = {
|
||||
default: 'icon.png',
|
||||
// TODO: need icons for different states
|
||||
// TODO: need higher density icons @3x, @4x, @5x
|
||||
// TODO: need to test with 20x20 & 24x24 icon sizes
|
||||
recording: 'recording.png',
|
||||
playback: 'playback.png',
|
||||
connected: 'connected.png',
|
||||
nominal: 'nominal.png',
|
||||
reconnecting: 'reconnecting.png',
|
||||
lost: 'lost.png',
|
||||
}
|
||||
state[attribute] = newState;
|
||||
|
||||
const TRAY_STATUS = {
|
||||
playback: 'playback',
|
||||
recording: 'recording',
|
||||
...CONNECTION_STATUS
|
||||
};
|
||||
// convert boolean to number
|
||||
const bi = (b: boolean) => b ? 1 : 0;
|
||||
|
||||
// Make sure we have the icon we're looking for.
|
||||
tray.setImage(
|
||||
ICON_PATH +
|
||||
`${bi(state.recording)}${bi(state.playback)}${bi(state.disconnect)}` +
|
||||
".png"
|
||||
);
|
||||
|
||||
const ICONS: Icons = {
|
||||
default: iconFactory(ICON_NAMES.default),
|
||||
[TRAY_STATUS.recording]: iconFactory(ICON_NAMES.recording),
|
||||
[TRAY_STATUS.playback]: iconFactory(ICON_NAMES.playback),
|
||||
[TRAY_STATUS.connected]: iconFactory(ICON_NAMES.connected),
|
||||
[TRAY_STATUS.nominal]: iconFactory(ICON_NAMES.nominal),
|
||||
[TRAY_STATUS.reconnecting]: iconFactory(ICON_NAMES.reconnecting),
|
||||
[TRAY_STATUS.lost]: iconFactory(ICON_NAMES.lost),
|
||||
};
|
||||
|
||||
backgroundMitt.on('update-icon-status', (payload: string) => {
|
||||
updateTray(payload);
|
||||
});
|
||||
|
||||
const changeIcon = (icon: NativeImage): void => tray ? tray.setImage(icon): undefined;
|
||||
|
||||
function iconFactory(iconName: string): NativeImage {
|
||||
if (iconName !== 'icon.png') {
|
||||
console.log('ERROR: Missing Icons: ', iconName);
|
||||
}
|
||||
// TODO: remove hard code, need icons for different states
|
||||
const iconBuffer = fs.readFileSync(path.join(__static, TRAY_ICON_DIR, 'icon.png'));
|
||||
const icon = nativeImage.createFromBuffer(iconBuffer);
|
||||
return icon;
|
||||
}
|
||||
|
||||
function updateTray(status: string): void {
|
||||
// read icon state
|
||||
const current = getIconState();
|
||||
if (current !== status) {
|
||||
setIconState(status);
|
||||
if (status in ICONS) {
|
||||
changeIcon(ICONS[status]);
|
||||
} else {
|
||||
changeIcon(ICONS.default);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initTray(): void {
|
||||
if (!tray) {
|
||||
try {
|
||||
tray = new Tray(ICONS.default);
|
||||
} catch(e) {
|
||||
console.log('Error initilizing tray', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tray = new Tray(ICON_PATH + "000.png");
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ interface Message {
|
|||
|
||||
interface Content {
|
||||
text: string;
|
||||
audio: string | boolean;
|
||||
audio: Int16Array | false;
|
||||
}
|
||||
|
||||
interface Context {
|
||||
|
|
|
|||
|
|
@ -1,95 +1,102 @@
|
|||
import { ipcEmit } from "@/composables/useEmitter";
|
||||
|
||||
class Profile implements Profile{
|
||||
class Profile implements Profile
|
||||
{
|
||||
crimata_id = "";
|
||||
name = "";
|
||||
photo = "";
|
||||
}
|
||||
|
||||
export default class UIState {
|
||||
|
||||
export default class UIState
|
||||
{
|
||||
profile: Profile;
|
||||
messages: Message[];
|
||||
|
||||
constructor() {
|
||||
constructor()
|
||||
{
|
||||
this.profile = new Profile();
|
||||
this.messages = [];
|
||||
}
|
||||
|
||||
emit() {
|
||||
emit()
|
||||
{
|
||||
ipcEmit("set-profile", this.profile);
|
||||
ipcEmit("init-messages", this.messages);
|
||||
}
|
||||
|
||||
set (message: Init) {
|
||||
set (message: Init)
|
||||
{
|
||||
this.profile = message.profile;
|
||||
this.messages = message.messages;
|
||||
this.emit();
|
||||
}
|
||||
|
||||
update(update: Update) {
|
||||
|
||||
if (update.name === "profile") {
|
||||
update(update: Update)
|
||||
{
|
||||
if (update.name === "profile")
|
||||
{
|
||||
this.profile = update.data as Profile;
|
||||
ipcEmit("set-profile", this.profile);
|
||||
}
|
||||
|
||||
else if (update.name === "add") {
|
||||
else if (update.name === "add")
|
||||
{
|
||||
this.messages.push(update.data as Message);
|
||||
ipcEmit("add-message", update.data);
|
||||
}
|
||||
|
||||
else if (update.name === "annotate") {
|
||||
else if (update.name === "annotate")
|
||||
{
|
||||
this._annotate(update.data as Annotation);
|
||||
ipcEmit("update-message", update.data);
|
||||
}
|
||||
|
||||
else {
|
||||
else
|
||||
{
|
||||
this._delete(update.data as string);
|
||||
ipcEmit("delete-message", update.data);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
reset() {
|
||||
reset()
|
||||
{
|
||||
this.profile = new Profile();
|
||||
this.messages = [];
|
||||
this.emit();
|
||||
}
|
||||
|
||||
_annotate(annotation: Annotation) {
|
||||
|
||||
for (const i in this.messages) {
|
||||
|
||||
if (this.messages[i].uid == annotation.uid) {
|
||||
|
||||
if (annotation.name == "content") {
|
||||
_annotate(annotation: Annotation)
|
||||
{
|
||||
for (const i in this.messages)
|
||||
{
|
||||
if (this.messages[i].uid == annotation.uid)
|
||||
{
|
||||
if (annotation.name == "content")
|
||||
{
|
||||
this.messages[i].content = annotation.data as Content[];
|
||||
}
|
||||
|
||||
if (annotation.name == "context") {
|
||||
if (annotation.name == "context")
|
||||
{
|
||||
this.messages[i].context = annotation.data as Context;
|
||||
}
|
||||
|
||||
if (annotation.name == "seen") {
|
||||
if (annotation.name == "seen")
|
||||
{
|
||||
this.messages[i].seen = annotation.data as boolean;
|
||||
}
|
||||
|
||||
ipcEmit("update-message", annotation);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
_delete(uid: string) {
|
||||
for (let i = 0; i < this.messages.length; i++) {
|
||||
if (this.messages[i].uid === uid) {
|
||||
_delete(uid: string)
|
||||
{
|
||||
for (let i = 0; i < this.messages.length; i++)
|
||||
{
|
||||
if (this.messages[i].uid === uid)
|
||||
{
|
||||
this.messages.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in a new issue