-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1 from alexpado/feature/vanilla-js
Feature/vanilla js
- Loading branch information
Showing
25 changed files
with
11,023 additions
and
12,847 deletions.
There are no files selected for viewing
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
Oops, something went wrong.