From c893304b5d4c643fd519546da36b7a2df5f5d5c4 Mon Sep 17 00:00:00 2001 From: vapormusic Date: Wed, 23 Feb 2022 22:17:04 +0700 Subject: [PATCH] chromecast (very botchy) --- package.json | 2 + src/main/base/browserwindow.ts | 1 + src/main/plugins/chromecast.ts | 337 +++++++++++++++++++++ src/renderer/audio/audio.js | 40 ++- src/renderer/index.js | 4 +- src/renderer/views/app/panels.ejs | 3 + src/renderer/views/app/sidebar.ejs | 4 + src/renderer/views/components/castmenu.ejs | 106 +++++++ src/renderer/views/svg/cast.svg | 5 +- 9 files changed, 483 insertions(+), 19 deletions(-) create mode 100644 src/main/plugins/chromecast.ts create mode 100644 src/renderer/views/components/castmenu.ejs diff --git a/package.json b/package.json index 0d447ef4..dbe9342b 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@sentry/electron": "^2.5.4", "@sentry/integrations": "^6.17.4", "adm-zip": "^0.5.9", + "castv2-client": "^1.2.0", "discord-rpc": "^4.0.1", "ejs": "^3.1.6", "electron-fetch": "^1.7.4", @@ -54,6 +55,7 @@ "mpris-service": "^2.1.2", "music-metadata": "^7.11.4", "node-gyp": "^8.4.1", + "node-ssdp": "^4.0.1", "qrcode": "^1.5.0", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/src/main/base/browserwindow.ts b/src/main/base/browserwindow.ts index b704a6b4..a237e7ef 100644 --- a/src/main/base/browserwindow.ts +++ b/src/main/base/browserwindow.ts @@ -78,6 +78,7 @@ export class BrowserWindow { "components/lyrics-view", "components/fullscreen", "components/miniplayer", + "components/castmenu" ], appRoutes: [ { diff --git a/src/main/plugins/chromecast.ts b/src/main/plugins/chromecast.ts new file mode 100644 index 00000000..2441f0e0 --- /dev/null +++ b/src/main/plugins/chromecast.ts @@ -0,0 +1,337 @@ +import * as electron from 'electron'; +import * as os from 'os'; +import {resolve} from 'path'; + +export default class ChromecastPlugin { + + /** + * Private variables for interaction in plugins + */ + private _win: any; + private _app: any; + private _lastfm: any; + private _store: any; + private _timer: any; + private audioClient = require('castv2-client').Client; + private mdns = require('mdns-js'); + + private devices : any = []; + private castDevices : any = []; + + // private GCRunning = false; + // private GCBuffer: any; + // private expectedConnections = 0; + // private currentConnections = 0; + private activeConnections : any = []; + // private requests = []; + // private GCstream = new Stream.PassThrough(), + private connectedHosts : any = {}; + // private port = false; + // private server = false; + // private bufcount = 0; + // private bufcount2 = 0; + // private headerSent = false; + + + private searchForGCDevices() { + try { + + let browser = this.mdns.createBrowser(this.mdns.tcp('googlecast')); + browser.on('ready', browser.discover); + + browser.on('update', (service :any) => { + if (service.addresses && service.fullname) { + this.ondeviceup(service.addresses[0], service.fullname.substring(0, service.fullname.indexOf("._googlecast")) + " " + (service.type[0].description ?? ""), '', 'googlecast'); + } + }); + const Client = require('node-ssdp').Client; + // also do a SSDP/UPnP search + let ssdpBrowser = new Client(); + ssdpBrowser.on('response', (headers :any , statusCode : any, rinfo: any) => { + var location = getLocation(headers); + if (location != null) { + this.getServiceDescription(location, rinfo.address); + } + + }); + + function getLocation(headers: any) { + let location = null; + if (headers["LOCATION"] != null ){location = headers["LOCATION"]} + else if (headers["Location"] != null ){location = headers["Location"]} + return location; + } + + ssdpBrowser.search('urn:dial-multiscreen-org:device:dial:1'); + + // // actual upnp devices + // if (app.cfg.get("audio.enableDLNA")) { + // let ssdpBrowser2 = new Client(); + // ssdpBrowser2.on('response', (headers, statusCode, rinfo) => { + // var location = getLocation(headers); + // if (location != null) { + // this.getServiceDescription(location, rinfo.address); + // } + + // }); + // ssdpBrowser2.search('urn:schemas-upnp-org:device:MediaRenderer:1'); + + // } + + + } catch (e) { + console.log('Search GC err', e); + } + } + + private getServiceDescription(url:any, address:any) { + const request = require('request'); + request.get(url, (error: any, response: any, body: any) => { + if (!error && response.statusCode === 200) { + this.parseServiceDescription(body, address, url); + } + }); + } + + private ondeviceup(host: any, name: any, location: any, type: any) { + if (this.castDevices.findIndex((item:any) => item.host === host && item.name === name && item.location === location && item.type === type) === -1) { + this.castDevices.push({ + name: name, + host: host, + location: location, + type: type + }); + if (this.devices.indexOf(host) === -1) { + this.devices.push(host); + } + if (name) { + this._win.webContents.executeJavaScript(`console.log('deviceFound','ip: ${host} name:${name}')`).catch((err: any) => console.error(err)); + console.log("deviceFound", host, name); + } + } else { + this._win.webContents.executeJavaScript(`console.log('deviceFound (added)','ip: ${host} name:${name}')`).catch((err: any) => console.error(err)); + console.log("deviceFound (added)", host, name); + } + } + + private parseServiceDescription(body: any, address: any, url: any) { + const parseString = require('xml2js').parseString; + parseString(body, (err: any, result: any) => { + if (!err && result && result.root && result.root.device) { + const device = result.root.device[0]; + console.log('device', device); + let devicetype = 'googlecast'; + console.log() + if (device.deviceType && device.deviceType.toString() === 'urn:schemas-upnp-org:device:MediaRenderer:1') { + devicetype = 'upnp'; + } + this.ondeviceup(address, device.friendlyName.toString(), url, devicetype); + } + }); + } + + + private loadMedia(client: any, song: any, artist: any, album: any, albumart: any, cb?: any) { + // const u = 'http://' + this.getIp() + ':' + server.address().port + '/'; + const DefaultMediaReceiver : any = require('castv2-client').DefaultMediaReceiver; + + client.launch(DefaultMediaReceiver, (err: any, player: any) => { + if (err) { + console.log(err); + return; + } + let media = { + // Here you can plug an URL to any mp4, webm, mp3 or jpg file with the proper contentType. + contentId: 'http://' + this.getIp() + ':9000/audio.webm', + contentType: 'audio/webm', + streamType: 'LIVE', // or LIVE + + // Title and cover displayed while buffering + metadata: { + type: 0, + metadataType: 3, + title: song ?? "", + albumName: album ?? "", + artist: artist ?? "", + images: [ + {url: albumart ?? ""}] + } + }; + + player.on('status', (status: any) => { + console.log('status broadcast playerState=%s', status); + }); + + console.log('app "%s" launched, loading media %s ...', player, media); + + player.load(media, { + autoplay: true + }, (err: any, status: any) => { + console.log('media loaded playerState=%s', status); + }); + + + client.getStatus((x: any, status: any) => { + if (status && status.volume) { + client.volume = status.volume.level; + client.muted = status.volume.muted; + client.stepInterval = status.volume.stepInterval; + } + }) + + }); + } + + private getIp() { + let ip = false; + let alias = 0; + let ifaces: any = os.networkInterfaces(); + for (var dev in ifaces) { + ifaces[dev].forEach((details:any) => { + if (details.family === 'IPv4') { + if (!/(loopback|vmware|internal|hamachi|vboxnet|virtualbox)/gi.test(dev + (alias ? ':' + alias : ''))) { + if (details.address.substring(0, 8) === '192.168.' || + details.address.substring(0, 7) === '172.16.' || + details.address.substring(0, 3) === '10.' + ) { + ip = details.address; + ++alias; + } + } + } + }); + } + return ip; + } + + private stream(device: any, song: any, artist: any, album: any, albumart: any) { + let castMode = 'googlecast'; + let UPNPDesc = ''; + castMode = device.type; + UPNPDesc = device.location; + + let client; + if (castMode === 'googlecast') { + let client = new this.audioClient(); + client.volume = 100; + client.stepInterval = 0.5; + client.muted = false; + + client.connect(device.host, () => { + // console.log('connected, launching app ...', 'http://' + this.getIp() + ':' + server.address().port + '/'); + if (!this.connectedHosts[device.host]) { + this.connectedHosts[device.host] = client; + this.activeConnections.push(client); + } + this.loadMedia(client, song, artist, album, albumart); + }); + + client.on('close', () => { + console.info("Client Closed"); + for (let i = this.activeConnections.length - 1; i >= 0; i--) { + if (this.activeConnections[i] === client) { + this.activeConnections.splice(i, 1); + return; + } + } + }); + + client.on('error', (err: any) => { + console.log('Error: %s', err.message); + client.close(); + delete this.connectedHosts[device.host]; + }); + + } else { + // upnp devices + //try { + // client = new MediaRendererClient(UPNPDesc); + // const options = { + // autoplay: true, + // contentType: 'audio/x-wav', + // dlnaFeatures: 'DLNA.ORG_PN=-;DLNA.ORG_OP=01;DLNA.ORG_FLAGS=01700000000000000000000000000000', + // metadata: { + // title: 'Apple Music Electron', + // creator: 'Streaming ...', + // type: 'audio', // can be 'video', 'audio' or 'image' + // // url: 'http://' + getIp() + ':' + server.address().port + '/', + // // protocolInfo: 'DLNA.ORG_PN=MP3;DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01700000000000000000000000000000; + // } + // }; + + // client.load('http://' + getIp() + ':' + server.address().port + '/a.wav', options, function (err, _result) { + // if (err) throw err; + // console.log('playing ...'); + // }); + + // } catch (e) { + // } + } + } + + private async setupGCServer(){ + return '' + } + + /** + * Base Plugin Details (Eventually implemented into a GUI in settings) + */ + public name: string = 'Chromecast'; + public description: string = 'LastFM plugin for Cider'; + public version: string = '0.0.1'; + public author: string = 'vapormusic / Cider Collective'; + + /** + * Runs on plugin load (Currently run on application start) + */ + constructor(app: any, store: any) { + this._app = app; + this._store = store + + } + + /** + * Runs on app ready + */ + onReady(win: any): void { + this._win = win; + electron.ipcMain.on('getKnownCastDevices', (event) => { + event.returnValue = this.castDevices + }); + + electron.ipcMain.on('performGCCast', (event, device, song, artist, album, albumart) => { + // this.setupGCServer().then( () => { + this._win.webContents.setAudioMuted(true); + console.log(device); + this.stream(device, song, artist, album, albumart); + // }) + }); + + electron.ipcMain.on('getChromeCastDevices', (_event, _data) => { + this.searchForGCDevices(); + }); + + electron.ipcMain.on('stopGCast', (_event) => { + this._win.webContents.setAudioMuted(false); + this.activeConnections = []; + this.connectedHosts = {}; + + }) + } + + /** + * Runs on app stop + */ + onBeforeQuit(): void { + + } + + /** + * Runs on song change + * @param attributes Music Attributes + */ + onNowPlayingItemDidChange(attributes: any): void { + + } + +} \ No newline at end of file diff --git a/src/renderer/audio/audio.js b/src/renderer/audio/audio.js index 322d776e..99cc43c3 100644 --- a/src/renderer/audio/audio.js +++ b/src/renderer/audio/audio.js @@ -9,11 +9,13 @@ var CiderAudio = { vibrantbassNode: null, llpw: null, llpwEnabled: null, - analogWarmth: null + analogWarmth: null, }, + ccON: false, + mediaRecorder: null, init: function (cb = function () { }) { //AudioOutputs.fInit = true; - searchInt = setInterval(function () { + let searchInt = setInterval(function () { if (document.getElementById("apple-music-player")) { //AudioOutputs.eqReady = true; document.getElementById("apple-music-player").crossOrigin = "anonymous"; @@ -138,19 +140,29 @@ var CiderAudio = { CiderAudio.hierarchical_loading(); }, sendAudio: function (){ - var options = { - mimeType : 'audio/webm; codecs=opus' - }; - var destnode = CiderAudio.context.createMediaStreamDestination(); - CiderAudio.audioNodes.gainNode.connect(destnode) - var mediaRecorder = new MediaRecorder(destnode.stream,options); - mediaRecorder.start(1); - mediaRecorder.ondataavailable = function(e) { - e.data.arrayBuffer().then(buffer => { - ipcRenderer.send('writeAudio',buffer) - } - ); + if (!CiderAudio.ccON) { + CiderAudio.ccON = true + let searchInt = setInterval(function () { + if (CiderAudio.context != null && CiderAudio.audioNodes.gainNode != null) { + var options = { + mimeType: 'audio/webm; codecs=opus' + }; + var destnode = CiderAudio.context.createMediaStreamDestination(); + CiderAudio.audioNodes.gainNode.connect(destnode) + var mediaRecorder = new MediaRecorder(destnode.stream, options); + mediaRecorder.start(1); + mediaRecorder.ondataavailable = function (e) { + e.data.arrayBuffer().then(buffer => { + ipcRenderer.send('writeAudio', buffer) + } + ); + } + + clearInterval(searchInt); + } + }, 1000); } + }, analogWarmth_h2_3: function (status, hierarchy){ if (status === true) { // 23 Band Adjustment diff --git a/src/renderer/index.js b/src/renderer/index.js index d55e9925..65206670 100644 --- a/src/renderer/index.js +++ b/src/renderer/index.js @@ -253,6 +253,7 @@ const app = new Vue({ pluginMenu: false, audioControls: false, showPlaylist: false, + castMenu: false }, socialBadges: { badgeMap: {}, @@ -269,7 +270,8 @@ const app = new Vue({ headerItems: {} } }, - pauseButtonTimer: null + pauseButtonTimer: null, + activeCasts: [] }, watch: { cfg: { diff --git a/src/renderer/views/app/panels.ejs b/src/renderer/views/app/panels.ejs index 65768149..78751e87 100644 --- a/src/renderer/views/app/panels.ejs +++ b/src/renderer/views/app/panels.ejs @@ -22,6 +22,9 @@ + + + diff --git a/src/renderer/views/app/sidebar.ejs b/src/renderer/views/app/sidebar.ejs index de511b78..69b66b07 100644 --- a/src/renderer/views/app/sidebar.ejs +++ b/src/renderer/views/app/sidebar.ejs @@ -96,6 +96,10 @@ <%- include("../svg/smartphone.svg") %> {{$root.getLz('action.showWebRemoteQR')}} +