Merge pull request #917 from ciderapp/airplay

Homepod streaming (non-password)
This commit is contained in:
vapormusic 2022-04-23 09:04:58 +07:00 committed by GitHub
commit daa3aa7244
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 303 additions and 8 deletions

View file

@ -38,9 +38,11 @@
"@sentry/electron": "^3.0.7", "@sentry/electron": "^3.0.7",
"@sentry/integrations": "^6.19.6", "@sentry/integrations": "^6.19.6",
"adm-zip": "0.4.10", "adm-zip": "0.4.10",
"airtunes2": "git+https://github.com/vapormusic/node_airtunes2.git",
"castv2-client": "^1.2.0", "castv2-client": "^1.2.0",
"chokidar": "^3.5.3", "chokidar": "^3.5.3",
"discord-rpc": "^4.0.1", "discord-rpc": "^4.0.1",
"dns-js": "git+https://github.com/ciderapp/node-dns-js.git",
"ejs": "^3.1.6", "ejs": "^3.1.6",
"electron-fetch": "^1.7.4", "electron-fetch": "^1.7.4",
"electron-log": "^4.4.6", "electron-log": "^4.4.6",
@ -52,7 +54,6 @@
"get-port": "^5.1.1", "get-port": "^5.1.1",
"jsonc": "^2.0.0", "jsonc": "^2.0.0",
"lastfmapi": "^0.1.1", "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", "mdns-js": "git+https://github.com/ciderapp/node-mdns-js.git",
"mpris-service": "^2.1.2", "mpris-service": "^2.1.2",
"music-metadata": "^7.12.3", "music-metadata": "^7.12.3",
@ -70,11 +71,11 @@
"youtube-search-without-api-key": "^1.0.7" "youtube-search-without-api-key": "^1.0.7"
}, },
"devDependencies": { "devDependencies": {
"@types/adm-zip": "^0.5.0",
"@types/discord-rpc": "4.0.2", "@types/discord-rpc": "4.0.2",
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
"@types/qrcode-terminal": "^0.12.0", "@types/qrcode-terminal": "^0.12.0",
"@types/ws": "^8.5.3", "@types/ws": "^8.5.3",
"@types/adm-zip": "^0.5.0",
"electron": "git+https://github.com/castlabs/electron-releases.git", "electron": "git+https://github.com/castlabs/electron-releases.git",
"electron-builder": "^23.0.3", "electron-builder": "^23.0.3",
"electron-builder-notarize-pkg": "^1.2.0", "electron-builder-notarize-pkg": "^1.2.0",

View file

@ -154,7 +154,8 @@ export class Store {
"advanced": { "advanced": {
"AudioContext": false, "AudioContext": false,
"experiments": [], "experiments": [],
"playlistTrackMapping": true "playlistTrackMapping": true,
"ffmpegLocation": ""
}, },
"connectUser": { "connectUser": {
"auth": null, "auth": null,

259
src/main/plugins/raop.ts Normal file
View file

@ -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'))
}
}
}

View file

@ -33,11 +33,27 @@
</div> </div>
</template> </template>
</div> </div>
<div class="md-labeltext" style="opacity:0.5;">{{$root.getLz('action.cast.airplay')}}</div> <div class="md-labeltext" >{{$root.getLz('action.cast.airplay')}}</div>
<div class="md-option-container" style="margin-top: 12px;margin-bottom: 12px;opacity:0.5;"> <div class="md-option-container" style="margin-top: 12px;margin-bottom: 12px;">
<div class="md-option-line"> <div class="md-option-line">
<div class="md-option-segment"> <div class="md-option-segment">
{{$root.getLz('action.cast.airplay.underdevelopment')}} {{$root.cfg.advanced.ffmpegLocation != "" ? 'Homepods only for now! (NO PASSWORD PLEASE!)' : 'Please add FFmpeg location in Settings -> Advanced'}}
<!-- {{$root.getLz('action.cast.airplay.underdevelopment')}} -->
<template v-if="$root.cfg.advanced.ffmpegLocation != ''" v-for="(device) in devices.airplay">
<div class="md-option-line" style="cursor: pointer" @click="setAirPlayCast(device)">
<div class="md-option-segment">
{{ device.name }}
<br>
<small>{{ device.host }}</small>
</div>
<div class="md-option-segment_auto" style="display: flex;justify-content: center;align-items: center" v-if="activeCasts.includes(device)">
Connected
</div>
<div class="md-option-segment_auto" v-else style="display: flex;justify-content: center;align-items: center">
<svg width="20" height="20" viewBox="0 0 34 34" fill="#fff" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" class="castPlayIndicator"><path d="M28.228,18.327l-16.023,8.983c-0.99,0.555 -2.205,-0.17 -2.205,-1.318l0,-17.984c0,-1.146 1.215,-1.873 2.205,-1.317l16.023,8.982c1.029,0.577 1.029,2.077 0,2.654Z" style="fill-rule:nonzero"></path></svg>
</div>
</div>
</template>
</div> </div>
</div> </div>
</div> </div>
@ -84,8 +100,10 @@
let self = this; let self = this;
this.scanning = true; this.scanning = true;
ipcRenderer.send('getChromeCastDevices', ''); ipcRenderer.send('getChromeCastDevices', '');
ipcRenderer.send("getAirplayDevice","")
setTimeout(() => { setTimeout(() => {
self.devices.cast = ipcRenderer.sendSync("getKnownCastDevices"); self.devices.cast = ipcRenderer.sendSync("getKnownCastDevices");
self.devices.airplay = ipcRenderer.sendSync("getKnownAirplayDevices");
self.scanning = false; self.scanning = false;
}, 2000); }, 2000);
console.log(this.devices); console.log(this.devices);
@ -96,8 +114,13 @@
this.activeCasts.push(device); this.activeCasts.push(device);
ipcRenderer.send('performGCCast', device, "Cider", "Playing ...", "Test build", ''); ipcRenderer.send('performGCCast', device, "Cider", "Playing ...", "Test build", '');
}, },
setAirPlayCast(device) {
this.activeCasts.push(device);
ipcRenderer.send("performAirplayPCM",device.host,device.port,null,"","","","")
},
stopCasting() { stopCasting() {
CiderAudio.stopAudio(); CiderAudio.stopAudio();
ipcRenderer.send('disconnectAirplay', '');
ipcRenderer.send('stopGCast', ''); ipcRenderer.send('stopGCast', '');
this.activeCasts = []; this.activeCasts = [];
// vm.$forceUpdate(); // vm.$forceUpdate();

View file

@ -915,6 +915,17 @@
</div> </div>
<div class="settings-option-body"> <div class="settings-option-body">
<div class="md-option-line" v-show="app.cfg.advanced.AudioContext">
<div class="md-option-segment">
FFmpeg location<br/>
<small>Restart needed to work. Required for AirPlay. (For example: C:\ffmpeg-4.4-essentials_build\bin\ffmpeg.exe)</small><br/>
<small>You can look at the internet on how to install it.</small>
</div>
<div class="md-option-segment md-option-segment_auto">
<input type="text" v-model="app.cfg.advanced.ffmpegLocation"/>
</div>
</div>
<div class="md-option-line"> <div class="md-option-line">
<div class="md-option-segment"> <div class="md-option-segment">
{{$root.getLz('settings.option.visual.plugin.github.explore')}} {{$root.getLz('settings.option.visual.plugin.github.explore')}}

View file

@ -1,7 +1,7 @@
{ {
"electronVersion": "16.0.07", "electronVersion": "18.0.3",
"electronDownload": { "electronDownload": {
"version": "16.0.7+wvcus", "version": "18.0.3+wvcus",
"mirror": "https://github.com/castlabs/electron-releases/releases/download/v" "mirror": "https://github.com/castlabs/electron-releases/releases/download/v"
}, },
"appId": "cider", "appId": "cider",