Skip to content

Commit

Permalink
Merge pull request #1 from alexpado/feature/vanilla-js
Browse files Browse the repository at this point in the history
Feature/vanilla js
  • Loading branch information
alexpado authored Feb 20, 2022
2 parents 6160e6a + b3419ad commit 4a3cc54
Show file tree
Hide file tree
Showing 25 changed files with 11,023 additions and 12,847 deletions.
3 changes: 0 additions & 3 deletions .browserslistrc

This file was deleted.

27 changes: 4 additions & 23 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,26 +1,7 @@
.DS_Store
# Evil, do not commit this
node_modules
/dist

# Might add configuration later, but for now, avoid configs altogether
.idea/

# local env files
.env.local
.env.*.local

# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

#Electron-builder output
/dist_electron
out/
57 changes: 39 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,48 @@
# camera-viewer-app

I've made this application just for the sole purpose of displaying my Nintendo Switch screen on my computer using an
HDMI to USB adapter which behave like a camera, which allows me to screen share it on Discord with sounds.
*Please note that this is a personal project. Its aim is not to be publicly used, but still, I will offer minimum
support if you encounter a bug (might as well accept feature request only if I'm not lazy too)*

As I'm playing few rhythm game, I needed to have a very low audio latency, which I was unable to find in other software.
## Why ?

This can be used with any video / audio devices as long as your computer can detect them as such.
I wanted to play games on my Switch and also screen share it on Discord to my friends. So I bought a HDMI to USB adapter
which would "emulate" a camera with microphone from the HDMI input stream (because capture cards are freaking expensive)
.

________
> I don't really plan to add a lot of feature or keep it frequently updated as it's a fairly simple app.
________
I found multiple software allowing to stream the video/audio stream, but none of them had video and audio synchronized
or had decent latency to not obliterate the game experience.

### TODO
So what was my last option ? DIY !

- [ ] Add volume control
- [ ] Double click to toggle fullscreen
- [ ] Video stream options (resolution, framerate, ...)
## Support

### Project Setup (NodeJS + NPM)
This software can support any video/audio stream as long as they are detected as input device by the OS. Although I
don't know how this would be useful streaming your camera with your microphone, but that works too.

```
git clone https://github.com/alexpado/camera-viewer-app.git
cd camera-viewer-app
npm install
npm run electron:serve
```
## Bug, Feature request

As stated above, I won't offer full support and active development on this project. As long as it works for me, I won't
update it.

If you ever encounter a bug, open an issue, I might look into it when I don't have better things to do.

If you have a feature request, do not hesitate to also open an issue, but keep in mind that I can straight up refuse
working on it due to my laziness (or wait for an undefined amount of time).

## How to use

Not really the most complicated software out there:

- Right click to lock/unlock the UI
- F11/Double click to enable fullscreen
- Scroll on the volume bar to change volume

## TODO

- [x] Change volume within the app
- [x] Toggle fullscreen with double click
- [ ] Video settings (resolution & framerate)
- [x] Add back the close button (you can use CTRL+W or ALT+F4 in the meantime)
- [ ] Auto device list refresh (restart or CTRL+R in the meantime)
- [x] Keep the volume level when switching audio source
- [x] Remember the last audio & video devices used
41 changes: 41 additions & 0 deletions assets/app/ActivityManager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export default class ActivityManager {

constructor(activityTimeout) {

this.activityTimeout = activityTimeout;
this.timeoutId = -1

document.addEventListener('mousemove', () => this.signalActivity());
}

get isActive() {
return document.body.classList.contains('active');
}

activityHook(element, event, callback) {

element.addEventListener(event, (ev) => {
this.signalActivity();
callback(ev);
}, {
passive: true
})
}

signalActivity() {
if (!this.isActive) {
document.body.classList.add('active');
}

if (this.timeoutId !== -1) {
clearTimeout(this.timeoutId)
}

this.timeoutId = setTimeout(() => {
if (this.isActive) {
document.body.classList.remove('active');
}
this.timeoutId = -1;
}, this.activityTimeout);
}
}
201 changes: 201 additions & 0 deletions assets/app/Application.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import Device from './Device.js';

export default class Application {

constructor() {

this.videoOptions = {
width: 1920,
height: 1080,
frameRate: 60,
latency: 0.02
}

this.audioOptions = {
noiseSuppression: false,
echoCancellation: false,
autoGainControl: false
}

this.activeAudioDevice = null;
this.activeVideoDevice = null;

this.availableAudioDevices = [];
this.availableVideoDevices = [];

this.videoStream = null;
this.audioStream = null;

this.audioContext = {
context: null,
gainControl: null,
volume: parseInt(localStorage.getItem('volume') ?? 100)
}

this.videoElement = document.querySelector('[data-tag="media"]')
}

get volume() {
return this.audioContext.volume;
}

set volume(value) {

if (value > 100) {
value = 100;
} else if (value < 0) {
value = 0;
}

if (this.audioContext.gainControl) {
this.audioContext.gainControl.gain.value = value / 100;
this.audioContext.volume = value;
localStorage.setItem('volume', value);
}
}

async detectAvailableDevices() {

const devices = await navigator.mediaDevices.enumerateDevices();

const audioDevices = devices
.filter(device => device.kind === 'audioinput')
.filter(device => device.deviceId.length === 64)
.map(device => new Device(this, device));

const videoDevices = devices
.filter(device => device.kind === 'videoinput')
.filter(device => device.deviceId.length === 64)
.map(device => new Device(this, device));

this.availableAudioDevices = audioDevices;
this.availableVideoDevices = videoDevices;

const lastAudioDevice = localStorage.getItem('lastAudioDevice');
const lastVideoDevice = localStorage.getItem('lastVideoDevice');

// Check if our device is still here
const audioDeviceStillPresent = audioDevices.filter(device => device.id === this.activeAudioDevice).length === 1;
const videoDeviceStillPresent = videoDevices.filter(device => device.id === this.activeVideoDevice).length === 1;

const lastAudioDeviceAvailable = audioDevices.filter(device => device.id === lastAudioDevice).length === 1;
const lastVideoDeviceAvailable = videoDevices.filter(device => device.id === lastVideoDevice).length === 1;


if (!audioDeviceStillPresent) {
if (lastAudioDeviceAvailable) {
console.log(`Application: Using last known audio device (ID: ${lastAudioDevice})`);
this.activeAudioDevice = lastAudioDevice;
} else if (audioDevices.length > 0) {
console.log(`Application: Using default audio device (ID: ${audioDevices[0].id})`);
this.activeAudioDevice = audioDevices[0].id;
} else {
alert('No audio device available.');
}
}

if (!videoDeviceStillPresent) {
if (lastVideoDeviceAvailable) {
console.log(`Application: Using last known video device (ID: ${lastVideoDevice})`);
this.activeVideoDevice = lastVideoDevice;
} else if (videoDevices.length > 0) {
console.log(`Application: Using default video device (ID: ${videoDevices[0].id})`);
this.activeVideoDevice = videoDevices[0].id;
} else {
alert('No video device available.');
}
}

this.refreshDeviceStatus();
}

refreshDeviceStatus() {

this.availableVideoDevices.forEach(device => {
device.enabled = device.id === this.activeVideoDevice;
});

this.availableAudioDevices.forEach(device => {
device.enabled = device.id === this.activeAudioDevice;
});
}

async switchAudioSource(deviceId) {
this.activeAudioDevice = deviceId;
localStorage.setItem('lastAudioDevice', deviceId);
this.refreshDeviceStatus();
await this.openAudioStream()
}

async switchVideoSource(deviceId) {
this.activeVideoDevice = deviceId;
localStorage.setItem('lastVideoDevice', deviceId);
this.refreshDeviceStatus();
await this.openVideoStream();
}

stopAudioStream() {
if (this.audioStream) {
this.audioStream.getTracks().forEach(track => {
track.stop();
});
}
this.audioStream = null;
}

stopVideoStream() {
if (this.videoStream) {
this.videoStream.getTracks().forEach(track => {
track.stop();
});
}
this.videoStream = null;
}

async openVideoStream() {
this.stopVideoStream();

if (this.activeVideoDevice) {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: this.activeVideoDevice,
...this.videoOptions
}
});

this.videoStream = stream;
this.videoElement.srcObject = stream;
this.videoElement.autoplay = true;
}
}

async openAudioStream() {
this.stopAudioStream();

if (this.activeAudioDevice) {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: this.activeAudioDevice,
...this.audioOptions
}
});

this.audioContext.context = new AudioContext();
this.audioContext.gainControl = this.audioContext.context.createGain();
const source = this.audioContext.context.createMediaStreamSource(stream);
source.connect(this.audioContext.gainControl);
this.audioContext.gainControl.connect(this.audioContext.context.destination);
this.volume = parseInt(localStorage.getItem('volume') ?? 100);
}
}

/**
* @param event
* @param {UI} ui
*/
onWheelScrolling(event, ui) {
const delta = event.deltaY / -20;
this.volume = this.volume + delta;
ui.setVolume(this.volume);
}
}
43 changes: 43 additions & 0 deletions assets/app/Device.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export default class Device {

/**
* @param {Application} app
* @param {MediaDeviceInfo} device
*/
constructor(app, device) {

this.app = app;
this.info = device;
this.id = device.deviceId;
this.name = device.label;

// Construct HTML Entity
this.item = document.createElement('div');
this.item.classList.add('cwa-device');
this.item.innerText = this.name;

this.item.addEventListener('click', () => {
if (device.kind === 'audioinput') {
app.switchAudioSource(this.id).then();
} else {
app.switchVideoSource(this.id).then();
}
})
}

get enabled() {
return this.html.classList.contains('active');
}

set enabled(value) {
if (value) {
this.html.classList.add('active');
} else {
this.html.classList.remove('active');
}
}

get html() {
return this.item;
}
}
Loading

0 comments on commit 4a3cc54

Please sign in to comment.