diff --git a/package.json b/package.json index 99046ccd..83f54bd5 100644 --- a/package.json +++ b/package.json @@ -38,9 +38,11 @@ "@sentry/electron": "^3.0.7", "@sentry/integrations": "^6.19.6", "adm-zip": "0.4.10", + "airtunes2": "git+https://github.com/vapormusic/node_airtunes2.git", "castv2-client": "^1.2.0", "chokidar": "^3.5.3", "discord-rpc": "^4.0.1", + "dns-js": "git+https://github.com/ciderapp/node-dns-js.git", "ejs": "^3.1.6", "electron-fetch": "^1.7.4", "electron-log": "^4.4.6", @@ -52,7 +54,6 @@ "get-port": "^5.1.1", "jsonc": "^2.0.0", "lastfmapi": "^0.1.1", - "dns-js": "git+https://github.com/ciderapp/node-dns-js.git", "mdns-js": "git+https://github.com/ciderapp/node-mdns-js.git", "mpris-service": "^2.1.2", "music-metadata": "^7.12.3", @@ -70,11 +71,11 @@ "youtube-search-without-api-key": "^1.0.7" }, "devDependencies": { + "@types/adm-zip": "^0.5.0", "@types/discord-rpc": "4.0.2", "@types/express": "^4.17.13", "@types/qrcode-terminal": "^0.12.0", "@types/ws": "^8.5.3", - "@types/adm-zip": "^0.5.0", "electron": "git+https://github.com/castlabs/electron-releases.git", "electron-builder": "^23.0.3", "electron-builder-notarize-pkg": "^1.2.0", diff --git a/src/main/base/store.ts b/src/main/base/store.ts index 8a6b999c..3436d807 100644 --- a/src/main/base/store.ts +++ b/src/main/base/store.ts @@ -154,7 +154,8 @@ export class Store { "advanced": { "AudioContext": false, "experiments": [], - "playlistTrackMapping": true + "playlistTrackMapping": true, + "ffmpegLocation": "" }, "connectUser": { "auth": null, diff --git a/src/main/plugins/raop.ts b/src/main/plugins/raop.ts new file mode 100644 index 00000000..2273e686 --- /dev/null +++ b/src/main/plugins/raop.ts @@ -0,0 +1,259 @@ +import * as electron from 'electron'; +import * as os from 'os'; +import * as fs from 'fs'; +import { join, resolve } from 'path'; +import * as CiderReceiver from '../base/castreceiver'; +import fetch from 'electron-fetch'; +import {Stream} from "stream"; +import {spawn} from 'child_process'; + + +export default class RAOP { + + /** + * Private variables for interaction in plugins + */ + private _utils: any; + private _win: any; + private _app: any; + private _store: any; + private _cacheAttr: any; + + private ipairplay: any = ""; + private portairplay: any = ""; + private u = require('airtunes2'); + private airtunes: any; + private device: any; + private mdns = require('mdns-js'); + private ok: any = 1; + private devices: any = []; + private castDevices: any = []; + private i: any = false; + private audioStream: any = new Stream.PassThrough(); + private ffmpeg: any = null; + + private ondeviceup(name: any, host: any, port: any, addresses: any) { + if (this.castDevices.findIndex((item: any) => item.name === host && item.port === port && item.addresses === addresses) === -1) { + this.castDevices.push({ + name: host, + host: addresses ? addresses[0] : '', + port: port, + addresses: addresses + }); + 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); + } + } + + /** + * Base Plugin Details (Eventually implemented into a GUI in settings) + */ + public name: string = 'RAOP'; + public description: string = 'RAOP Plugin'; + public version: string = '0.0.1'; + public author: string = 'vapormusic / Cider Collective'; + + /** + * Runs on plugin load (Currently run on application start) + */ + constructor(utils: { getStore: () => any; getApp: () => any; }) { + this._utils = utils; + console.debug(`[Plugin][${this.name}] Loading Complete.`); + this._app = utils.getApp(); + + } + + /** + * Runs on app ready + */ + onReady(win: any): void { + this._win = win; + + electron.ipcMain.on('getKnownAirplayDevices', (event) => { + event.returnValue = this.castDevices + }); + + electron.ipcMain.on("getAirplayDevice", (event, data) => { + this.castDevices = []; + console.log("scan for airplay devices"); + + const browser = this.mdns.createBrowser(this.mdns.tcp('raop')); + browser.on('ready', browser.discover); + + browser.on('update', (service: any) => { + if (service.addresses && service.fullname && service.fullname.includes('_raop._tcp')) { + this._win.webContents.executeJavaScript(`console.log( + "${service.name} ${service.host}:${service.port} ${service.addresses}" + )`);} + this.ondeviceup(service.name, service.host, service.port, service.addresses); + }); + + }); + + + + electron.ipcMain.on("performAirplayPCM", (event, ipv4, ipport, sepassword, title, artist, album, artworkURL) => { + + if (ipv4 != this.ipairplay || ipport != this.portairplay) { + if (this.airtunes == null) { this.airtunes = new this.u()} + this.ipairplay = ipv4; + this.portairplay = ipport; + this.device = this.airtunes.add(ipv4, { + port: ipport, + volume: 60, + password: sepassword, + }); + this.device.on('status', (status: any) => { + console.log('device status', status); + if (status == "ready"){ + this._win.webContents.executeJavaScript(`CiderAudio.sendAudio()`).catch((err: any) => console.error(err)); + } + if (status == 'stopped') { + this.airtunes.stopAll(() => { + console.log('end'); + }); + this.airtunes = null; + this.device = null; + this.ipairplay = ''; + this.portairplay = ''; + this.ok = 1; + + } else { + setTimeout(() => { + if (this.ok == 1) { + console.log(this.device.key, title ?? '', artist ?? '', album ?? ''); + this.airtunes.setTrackInfo(this.device.key, title ?? '', artist?? '', album?? ''); + this.uploadImageAirplay(artworkURL); + console.log('done'); + this.ok == 2 + } + }, 1000); + } + + + }); + + } + + + + + }); + + electron.ipcMain.on('writeWAV', (event) => { + if (this.airtunes != null) { + if (!this.i){ + this.ffmpeg != null ? this.ffmpeg.kill() : null; + this.ffmpeg = spawn(this._utils.getStoreValue("advanced.ffmpegLocation"), [ + '-f', 's16le', // PCM 16bits, little-endian + '-ar', '48000', + '-ac', "2", + '-i', "http://localhost:9000/audio.wav", + '-acodec', 'pcm_s16le', + '-f', 's16le', // PCM 16bits, little-endian + '-ar', '44100', // Sampling rate + '-ac', "2", // Stereo + 'pipe:1' // Output on stdout + ]); + + // pipe data to AirTunes + this.ffmpeg.stdout.pipe(this.airtunes); + this.i = true;}} + + }); + + + + electron.ipcMain.on('disconnectAirplay', (event) => { + this.airtunes.stopAll(function () { + console.log('end'); + }); + this.airtunes = null; + this.device = null; + this.ipairplay = ''; + this.portairplay = ''; + this.ok = 1; + this.i = false; + }); + + electron.ipcMain.on('updateAirplayInfo', (event, title, artist, album, artworkURL) => { + if (this.airtunes && this.device) { + console.log(this.device.key, title, artist, album); + this.airtunes.setTrackInfo(this.device.key, title, artist, album); + this.uploadImageAirplay(artworkURL) + } + }); + + electron.ipcMain.on('updateRPCImage', (_event, imageurl) => { + this.uploadImageAirplay(imageurl) + }) + + + + } + + private uploadImageAirplay = (url: any) => { + try { + if (url != null && url != '') { + //console.log(join(this._app.getPath('userData'), 'temp.png'), url); + fetch(url) + .then(res => res.buffer()) + .then((buffer) => { + this.airtunes.setArtwork(this.device.key, buffer, "image/png"); + }).catch(err => { + console.log(err) + }); + } + } catch (e) { console.log(e) } + } + + /** + * Runs on app stop + */ + onBeforeQuit(): void { + + } + + // /** + // * Runs on song change + // * @param attributes Music Attributes + // */ + // onNowPlayingItemDidChange(attributes: any): void { + // if (this.airtunes && this.device) { + // let title = attributes.name ? attributes.name : ''; + // let artist = attributes.artistName ? attributes.artistName : ''; + // let album = attributes.albumName ? attributes.albumName : ''; + // let artworkURL = attributes?.artwork?.url?.replace('{w}', '1024').replace('{h}', '1024') ?? null; + // console.log(this.device.key, title, artist, album); + // this.airtunes.setTrackInfo(this.device.key, title, artist, album); + // if (artworkURL) + // this.uploadImageAirplay(artworkURL) + // } + // } + + /** + * Runs on playback State Change + * @param attributes Music Attributes (attributes.status = current state) + */ + onPlaybackStateDidChange(attributes: any): void { + if (this.airtunes && this.device) { + let title = attributes?.name ?? ''; + let artist = attributes?.artistName ?? ''; + let album = attributes?.albumName ?? ''; + let artworkURL = attributes?.artwork?.url ?? null; + console.log(this.device.key, title, artist, album); + this.airtunes.setTrackInfo(this.device.key, title, artist, album); + if (artworkURL != null){} + this.uploadImageAirplay(artworkURL.replace('{w}', '1024').replace('{h}', '1024')) + } + } + +} \ No newline at end of file diff --git a/src/renderer/views/components/castmenu.ejs b/src/renderer/views/components/castmenu.ejs index 2cf42e54..39fcaa43 100644 --- a/src/renderer/views/components/castmenu.ejs +++ b/src/renderer/views/components/castmenu.ejs @@ -33,11 +33,27 @@ -
{{$root.getLz('action.cast.airplay')}}
-
+
{{$root.getLz('action.cast.airplay')}}
+
- {{$root.getLz('action.cast.airplay.underdevelopment')}} + {{$root.cfg.advanced.ffmpegLocation != "" ? 'Homepods only for now! (NO PASSWORD PLEASE!)' : 'Please add FFmpeg location in Settings -> Advanced'}} + +
@@ -84,8 +100,10 @@ let self = this; this.scanning = true; ipcRenderer.send('getChromeCastDevices', ''); + ipcRenderer.send("getAirplayDevice","") setTimeout(() => { self.devices.cast = ipcRenderer.sendSync("getKnownCastDevices"); + self.devices.airplay = ipcRenderer.sendSync("getKnownAirplayDevices"); self.scanning = false; }, 2000); console.log(this.devices); @@ -96,8 +114,13 @@ this.activeCasts.push(device); ipcRenderer.send('performGCCast', device, "Cider", "Playing ...", "Test build", ''); }, + setAirPlayCast(device) { + this.activeCasts.push(device); + ipcRenderer.send("performAirplayPCM",device.host,device.port,null,"","","","") + }, stopCasting() { CiderAudio.stopAudio(); + ipcRenderer.send('disconnectAirplay', ''); ipcRenderer.send('stopGCast', ''); this.activeCasts = []; // vm.$forceUpdate(); diff --git a/src/renderer/views/pages/settings.ejs b/src/renderer/views/pages/settings.ejs index 1de80810..65f39bfd 100644 --- a/src/renderer/views/pages/settings.ejs +++ b/src/renderer/views/pages/settings.ejs @@ -915,6 +915,17 @@
+
+
+ FFmpeg location
+ Restart needed to work. Required for AirPlay. (For example: C:\ffmpeg-4.4-essentials_build\bin\ffmpeg.exe)
+ You can look at the internet on how to install it. +
+
+ +
+
+
{{$root.getLz('settings.option.visual.plugin.github.explore')}} diff --git a/winget.json b/winget.json index 4468a361..5b62c059 100644 --- a/winget.json +++ b/winget.json @@ -1,7 +1,7 @@ { - "electronVersion": "16.0.07", + "electronVersion": "18.0.3", "electronDownload": { - "version": "16.0.7+wvcus", + "version": "18.0.3+wvcus", "mirror": "https://github.com/castlabs/electron-releases/releases/download/v" }, "appId": "cider",