Merge branch 'develop'

This commit is contained in:
child_duckling 2022-03-08 18:07:59 -08:00
commit df2f7b7216
195 changed files with 62285 additions and 12895 deletions

View file

@ -0,0 +1,342 @@
import * as electron from 'electron';
import * as os from 'os';
import {resolve} from 'path';
import * as CiderReceiver from '../base/castreceiver';
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 && service.fullname.includes('_googlecast._tcp')) {
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(CiderReceiver, (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.wav',
contentType: 'audio/wav',
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;
}
})
// send websocket ip
player.sendIp("ws://"+this.getIp()+":26369");
});
}
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 {
}
}

View file

@ -1,4 +1,6 @@
import * as RPC from 'discord-rpc'
import {ipcMain} from "electron";
import fetch from 'electron-fetch'
export default class DiscordRichPresence {
@ -6,6 +8,8 @@ export default class DiscordRichPresence {
* Private variables for interaction in plugins
*/
private static _store: any;
private _app : any;
private _attributes : any;
private static _connection: boolean = false;
/**
@ -29,6 +33,7 @@ export default class DiscordRichPresence {
smallImageText: '',
instance: false
};
private _activityCache: RPC.Presence = {
details: '',
state: '',
@ -58,7 +63,6 @@ export default class DiscordRichPresence {
// Create the client
this._client = new RPC.Client({transport: "ipc"});
// Runs on Ready
this._client.on('ready', () => {
console.info(`[DiscordRPC][connect] Successfully Connected to Discord. Authed for user: ${this._client.user.id}.`);
@ -90,6 +94,44 @@ export default class DiscordRichPresence {
}).catch((e: any) => console.error(`[DiscordRPC][disconnect] ${e}`));
}
/**
* Filter the Discord activity object
*/
private filterActivity(activity: any, attributes: any): Object {
// Checks if the name is greater than 128 because some songs can be that long
if (activity.details && activity.details.length > 128) {
activity.details = activity.details.substring(0, 125) + '...'
}
// Check large image
if (activity.largeImageKey == null || activity.largeImageKey === "" || activity.largeImageKey.length > 256) {
activity.largeImageKey = "cider";
}
// Timestamp
if (new Date(attributes.endTime).getTime() < 0) {
delete activity.startTime
delete activity.endTime
}
// not sure
if (!attributes.artistName) {
delete activity.state;
}
if (!activity.largeImageText || activity.largeImageText.length < 2) {
delete activity.largeImageText
}
activity.buttons.forEach((key: {label: string, url: string}, _v: Number) => {
if (key.url.includes('undefined') || key.url.includes('no-id-found')) {
activity.buttons.splice(key, 1);
}
})
return activity
}
/**
* Sets the activity of the client
* @param {object} attributes
@ -105,23 +147,18 @@ export default class DiscordRichPresence {
this._activity = {
details: attributes.name,
state: `${attributes.artistName ? `by ${attributes.artistName}` : ''}`,
startTimestamp: ((new Date(attributes.endTime).getTime() < 0) ? null : attributes.startTime),
endTimestamp: ((new Date(attributes.endTime).getTime() < 0) ? null : attributes.endTime),
largeImageKey: (attributes.artwork.url.replace('{w}', '1024').replace('{h}', '1024')) ?? 'cider',
startTimestamp: attributes.startTime,
endTimestamp: attributes.endTime,
largeImageKey: attributes?.artwork?.url?.replace('{w}', '1024').replace('{h}', '1024'),
largeImageText: attributes.albumName,
instance: false, // Whether the activity is in a game session
buttons: [
{label: "Listen on Cider", url: attributes.url.cider},
{label: "View on Apple Music", url: attributes.url.appleMusic},
]
] //To change attributes.url => preload/cider-preload.js
};
// Checks if the name is greater than 128 because some songs can be that long
if (this._activity.details && this._activity.details.length > 128) {
this._activity.details = this._activity.details.substring(0, 125) + '...'
}
this._activity = this.filterActivity(this._activity, attributes)
// Check if its pausing (false) or playing (true)
if (!attributes.status) {
@ -136,7 +173,6 @@ export default class DiscordRichPresence {
this._client.setActivity(this._activity)
.catch((e: any) => console.error(`[DiscordRichPresence][setActivity] ${e}`));
}
} else if (this._activity && this._activityCache !== this._activity && this._activity.details) {
if (!DiscordRichPresence._store.general.discord_rpc_clear_on_pause) {
this._activity.smallImageKey = 'play';
@ -157,17 +193,37 @@ export default class DiscordRichPresence {
/**
* Runs on plugin load (Currently run on application start)
*/
constructor(_app: any, store: any) {
constructor(app: any, store: any) {
DiscordRichPresence._store = store
console.debug(`[Plugin][${this.name}] Loading Complete.`);
this._app = app;
}
/**
* Runs on app ready
*/
onReady(_win: any): void {
let self = this
this.connect((DiscordRichPresence._store.general.discord_rpc == 1) ? '911790844204437504' : '886578863147192350');
console.debug(`[Plugin][${this.name}] Ready.`);
ipcMain.on('updateRPCImage', (_event, imageurl) => {
if (!DiscordRichPresence._store.general.privateEnabled){
fetch('https://api.cider.sh/v1/images' ,{
method: 'POST',
body: JSON.stringify({url : imageurl}),
headers: {
'Content-Type': 'application/json',
'User-Agent': _win.webContents.getUserAgent()
},
})
.then(res => res.json())
.then(function(json){
self._attributes["artwork"]["url"] = json.url
self.updateActivity(self._attributes)
})
}
})
}
/**
@ -179,10 +235,12 @@ export default class DiscordRichPresence {
/**
* Runs on playback State Change
* @param attributes Music Attributes (attributes.state = current state)
* @param attributes Music Attributes (attributes.status = current state)
*/
onPlaybackStateDidChange(attributes: object): void {
this.updateActivity(attributes)
if (!DiscordRichPresence._store.general.privateEnabled){
this._attributes = attributes
this.updateActivity(attributes)}
}
/**
@ -190,6 +248,8 @@ export default class DiscordRichPresence {
* @param attributes Music Attributes
*/
onNowPlayingItemDidChange(attributes: object): void {
this.updateActivity(attributes)
if (!DiscordRichPresence._store.general.privateEnabled){
this._attributes = attributes
this.updateActivity(attributes)}
}
}

View file

@ -15,6 +15,7 @@ export default class LastFMPlugin {
private _app: any;
private _lastfm: any;
private _store: any;
private _timer: any;
private authenticateFromFile() {
let sessionData = require(this.sessionPath)
@ -77,64 +78,52 @@ export default class LastFMPlugin {
}
}
private async scrobbleSong(attributes: any) {
await new Promise(resolve => setTimeout(resolve, Math.round(attributes.durationInMillis * (this._store.lastfm.scrobble_after / 100))));
const currentAttributes = attributes;
private scrobbleSong(attributes: any) {
if (this._timer) clearTimeout(this._timer);
var self = this;
this._timer = setTimeout(async () => {
const currentAttributes = attributes;
if (!this._lastfm || this._lastfm.cachedAttributes === attributes) {
return
}
if (!self._lastfm || self._lastfm.cachedAttributes === attributes) {
return
}
if (this._lastfm.cachedAttributes) {
if (this._lastfm.cachedAttributes.playParams.id === attributes.playParams.id) return;
}
if (self._lastfm.cachedAttributes) {
if (self._lastfm.cachedAttributes.playParams.id === attributes.playParams.id) return;
}
if (currentAttributes.status && currentAttributes === attributes) {
if (fs.existsSync(this.sessionPath)) {
// Scrobble playing song.
if (attributes.status === true) {
this._lastfm.track.scrobble({
'artist': this.filterArtistName(attributes.artistName),
'track': attributes.name,
'album': attributes.albumName,
'albumArtist': this.filterArtistName(attributes.artistName),
'timestamp': new Date().getTime() / 1000
}, function (err: any, scrobbled: any) {
if (err) {
return console.error('[LastFM] An error occurred while scrobbling', err);
}
const artist = await this.getPrimaryArtist(attributes)
const album = this.getAlbumName(attributes)
console.log('[LastFM] Successfully scrobbled: ', scrobbled);
});
this._lastfm.cachedAttributes = attributes
if (currentAttributes.status && currentAttributes === attributes) {
if (fs.existsSync(this.sessionPath)) {
// Scrobble playing song.
if (attributes.status === true) {
self._lastfm.track.scrobble({
'artist': artist,
'track': attributes.name,
'album': album,
'albumArtist': artist,
'timestamp': new Date().getTime() / 1000
}, function (err: any, scrobbled: any) {
if (err) {
return console.error('[LastFM] An error occurred while scrobbling', err);
}
console.log('[LastFM] Successfully scrobbled: ', scrobbled);
});
self._lastfm.cachedAttributes = attributes
}
} else {
self.authenticate();
}
} else {
this.authenticate();
return console.log('[LastFM] Did not add ', attributes.name, '—', artist, 'because now playing a other song.');
}
} else {
return console.log('[LastFM] Did not add ', attributes.name, '—', this.filterArtistName(attributes.artistName), 'because now playing a other song.');
}
}, Math.round(attributes.durationInMillis * Math.min((self._store.lastfm.scrobble_after / 100),0.8)));
}
private filterArtistName(artist: any) {
if (!this._store.lastfm.enabledRemoveFeaturingArtists) return artist;
artist = artist.split(' ');
if (artist.includes('&')) {
artist.length = artist.indexOf('&');
}
if (artist.includes('and')) {
artist.length = artist.indexOf('and');
}
artist = artist.join(' ');
if (artist.includes(',')) {
artist = artist.split(',')
artist = artist[0]
}
return artist.charAt(0).toUpperCase() + artist.slice(1);
}
private updateNowPlayingSong(attributes: any) {
private async updateNowPlayingSong(attributes: any) {
if (!this._lastfm || this._lastfm.cachedNowPlayingAttributes === attributes || !this._store.lastfm.NowPlaying) {
return
}
@ -144,13 +133,16 @@ export default class LastFMPlugin {
}
if (fs.existsSync(this.sessionPath)) {
const artist = await this.getPrimaryArtist(attributes)
const album = this.getAlbumName(attributes)
// update Now Playing
if (attributes.status === true) {
this._lastfm.track.updateNowPlaying({
'artist': this.filterArtistName(attributes.artistName),
'artist': artist,
'track': attributes.name,
'album': attributes.albumName,
'albumArtist': this.filterArtistName(attributes.artistName)
'album': album,
'albumArtist': artist
}, function (err: any, nowPlaying: any) {
if (err) {
return console.error('[LastFM] An error occurred while updating nowPlayingSong', err);
@ -166,6 +158,44 @@ export default class LastFMPlugin {
}
}
private getAlbumName(attributes: any): string {
return attributes.albumName.replace(/ - Single| - EP/g, '');
}
private async getPrimaryArtist(attributes: any) {
const songId = attributes.playParams.catalogId || attributes.playParams.id
if (!this._store.lastfm.enabledRemoveFeaturingArtists || !songId) return attributes.artistName;
const res = await this._win.webContents.executeJavaScript(`
(async () => {
const subMk = await MusicKit.getInstance().api.v3.music("/v1/catalog/" + MusicKit.getInstance().storefrontId + "/songs/${songId}", {
include: {
songs: ["artists"]
}
})
if (!subMk) console.error('[LastFM] Request failed: /v1/catalog/us/songs/${songId}')
return subMk.data
})()
`).catch(console.error)
if (!res) return attributes.artistName
const data = res.data
if (!data.length) {
console.error(`[LastFM] Unable to locate song with id of ${songId}`)
return attributes.artistName;
}
const artists = res.data[0].relationships.artists.data
if (!artists.length) {
console.error(`[LastFM] Unable to find artists related to the song with id of ${songId}`)
return attributes.artistName;
}
const primaryArtist = artists[0]
return primaryArtist.attributes.name
}
/**
* Base Plugin Details (Eventually implemented into a GUI in settings)
*/
@ -229,25 +259,19 @@ export default class LastFMPlugin {
console.log('Example plugin stopped');
}
/**
* Runs on playback State Change
* @param attributes Music Attributes (attributes.state = current state)
*/
onPlaybackStateDidChange(attributes: object): void {
this.scrobbleSong(attributes)
this.updateNowPlayingSong(attributes)
}
/**
* Runs on song change
* @param attributes Music Attributes
*/
onNowPlayingItemDidChange(attributes: object): void {
if (!this._store.lastfm.filterLoop){
nowPlayingItemDidChangeLastFM(attributes: any): void {
if (!this._store.general.privateEnabled){
attributes.status = true
if (!this._store.lastfm.filterLoop) {
this._lastfm.cachedNowPlayingAttributes = false;
this._lastfm.cachedAttributes = false}
this.scrobbleSong(attributes)
this._lastfm.cachedAttributes = false
}
this.updateNowPlayingSong(attributes)
this.scrobbleSong(attributes)}
}
}
}

254
src/main/plugins/menubar.ts Normal file
View file

@ -0,0 +1,254 @@
import {app, Menu, shell} from "electron";
export default class Thumbar {
/**
* Private variables for interaction in plugins
*/
private _win: any;
private _app: any;
private _store: any;
/**
* Base Plugin Details (Eventually implemented into a GUI in settings)
*/
public name: string = 'Menubar Plugin';
public description: string = 'Creates the menubar';
public version: string = '1.0.0';
public author: string = 'Core / Quacksire';
/**
* Thumbnail Toolbar Assets
* NATIVE-IMAGE DOESN'T SUPPORT SVG
private icons: { [key: string]: Electron.NativeImage } = {
remoteIcon: nativeImage.createFromPath(join(utils.getPath('rendererPath'), 'views/svg/smartphone.svg')).toPNG(),
soundIcon: nativeImage.createFromPath(join(utils.getPath('rendererPath'), 'views/svg/headphones.svg')).toPNG(),
aboutIcon: nativeImage.createFromPath(join(utils.getPath('rendererPath'), 'views/svg/info.svg')).toPNG(),
settingsIcon: nativeImage.createFromPath(join(utils.getPath('rendererPath'), 'views/svg/settings.svg')).toPNG(),
logoutIcon: nativeImage.createFromPath(join(utils.getPath('rendererPath'), 'views/svg/log-out.svg')).toPNG(),
ciderIcon: nativeImage.createFromPath(join(utils.getPath('rendererPath'), 'assets/logocute.png')).toPNG(),
}
*/
/**
* Menubar Assets
* @private
*/
private isMac: boolean = process.platform === 'darwin';
private menubarTemplate: any = [
{
label: app.getName(),
submenu: [
{
label: 'About',
click: () => this._win.webContents.executeJavaScript(`app.appRoute('about')`)
},
{ type: 'separator' },
{
label: 'Settings',
accelerator: 'CommandOrControl+,',
click: () => this._win.webContents.executeJavaScript(`app.appRoute('settings')`)
},
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' }
]
},
{
label: 'View',
submenu: [
{role: 'reload'},
{role: 'forceReload'},
{role: 'toggleDevTools'},
{type: 'separator'},
{role: 'resetZoom'},
{role: 'zoomIn'},
{role: 'zoomOut'},
{type: 'separator'},
{role: 'togglefullscreen'},
]
},
{
label: 'Window',
submenu: [
{role: 'minimize'},
{role: 'zoom'},
...(this.isMac ? [
{type: 'separator'},
{role: 'front'},
] : [
{role: 'close'}
]),
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
]
},
{type: 'separator'},
{
label: 'Web Remote',
accelerator: 'CommandOrControl+Shift+W',
sublabel: 'Opens in external window',
click: () => this._win.webContents.executeJavaScript(`ipcRenderer.invoke('showQR')`)
},
{
label: 'Audio Settings',
accelerator: 'CommandOrControl+Shift+A',
click: () => this._win.webContents.executeJavaScript(`app.modals.audioSettings = true`)
},
{
label: 'Plug-in Menu',
accelerator: 'CommandOrControl+Shift+P',
click: () => this._win.webContents.executeJavaScript(`app.modals.pluginMenu = true`)
}
]
},
{
label: 'Controls',
submenu: [
{
label: 'Pause / Play',
accelerator: 'Space',
click: () => this._win.webContents.executeJavaScript(`app.SpacePause()`)
},
{
label: 'Next',
accelerator: 'CommandOrControl+Right',
click: () => this._win.webContents.executeJavaScript(`MusicKitInterop.next()`)
},
{
label: 'Previous',
accelerator: 'CommandOrControl+Left',
click: () => this._win.webContents.executeJavaScript(`MusicKitInterop.previous()`)
},
{ type: 'separator' },
{
label: 'Volume Up',
accelerator: 'CommandOrControl+Up',
click: () => this._win.webContents.executeJavaScript(`app.volumeUp()`)
},
{
label: 'Volume Down',
accelerator: 'CommandOrControl+Down',
click: () => this._win.webContents.executeJavaScript(`app.volumeDown()`)
}
]
},
{
label: 'Account',
submenu: [
{
label: 'Account Settings',
click: () => this._win.webContents.executeJavaScript(`app.appRoute('apple-account-settings')`)
},
{
label: 'Sign Out',
click: () => this._win.webContents.executeJavaScript(`app.unauthorize()`)
}
]
},
{
label: 'Support',
role: 'help',
submenu: [
{
label: 'Discord',
click: () => shell.openExternal("https://discord.gg/AppleMusic").catch(console.error)
},
{
label: 'GitHub Wiki',
click: () => shell.openExternal("https://github.com/ciderapp/Cider/wiki/Troubleshooting").catch(console.error)
},
{type: 'separator'},
{
label: 'Report a...',
submenu: [
{
label: 'Bug',
click: () => shell.openExternal("https://github.com/ciderapp/Cider/issues/new?assignees=&labels=bug%2Ctriage&template=bug_report.yaml&title=%5BBug%5D%3A+").catch(console.error)
},
{
label: 'Feature Request',
click: () => shell.openExternal("https://github.com/ciderapp/Cider/issues/new?assignees=&labels=enhancement%2Ctriage&template=feature_request.yaml&title=%5BEnhancement%5D%3A+").catch(console.error)
},
{
label: 'Translation Report/Request',
click: () => shell.openExternal("https://github.com/ciderapp/Cider/issues/new?assignees=&labels=%F0%9F%8C%90+Translations&template=translation.yaml&title=%5BTranslation%5D%3A+").catch(console.error)
},
]
},
{type: 'separator'},
{
label: 'View License',
click: () => shell.openExternal("https://github.com/ciderapp/Cider/blob/main/LICENSE").catch(console.error)
},
{type: 'separator'},
{
label: 'Toggle Developer Tools',
accelerator: 'Option+CommandOrControl+I',
click: () => this._win.webContents.openDevTools()
},
{
label: 'Open Configuration File in Editor',
click: () => this._store.openInEditor()
}
]
}
]
/*******************************************************************************************
* Public Methods
* ****************************************************************************************/
/**
* Runs on plugin load (Currently run on application start)
*/
constructor(app: any, store: any) {
this._app = app;
this._store = store
console.debug(`[Plugin][${this.name}] Loading Complete.`);
}
/**
* Runs on app ready
*/
onReady(win: Electron.BrowserWindow): void {
this._win = win;
Menu.setApplicationMenu(Menu.buildFromTemplate(this.menubarTemplate))
}
/**
* Runs on app stop
*/
onBeforeQuit(): void {
console.debug(`[Plugin][${this.name}] Stopped.`);
}
/**
* Runs on playback State Change
* @param attributes Music Attributes (attributes.status = current state)
*/
onPlaybackStateDidChange(attributes: object): void {
}
/**
* Runs on song change
* @param attributes Music Attributes
*/
onNowPlayingItemDidChange(attributes: object): void {
}
}

View file

@ -174,7 +174,11 @@ export default class MPRIS {
*/
onBeforeQuit(): void {
console.debug(`[Plugin][${this.name}] Stopped.`);
this.clearState()
try {
this.clearState()
}catch(e) {
console.error(e)
}
}
/**

133
src/main/plugins/thumbar.ts Normal file
View file

@ -0,0 +1,133 @@
import {nativeImage, nativeTheme} from "electron";
import {utils} from "../base/utils";
import {join} from "path";
export default class Thumbar {
/**
* Private variables for interaction in plugins
*/
private _win: any;
private _app: any;
/**
* Base Plugin Details (Eventually implemented into a GUI in settings)
*/
public name: string = 'Thumbnail Toolbar Plugin';
public description: string = 'Creates and managed the thumbnail toolbar buttons and their events';
public version: string = '1.0.0';
public author: string = 'Core';
/**
* Thumbnail Toolbar Assets
*/
private icons: { [key: string]: Electron.NativeImage } = {
pause: nativeImage.createFromPath(join(utils.getPath('resourcePath'), 'icons/thumbar', `${nativeTheme.shouldUseDarkColors ? 'light' : 'dark'}_pause.png`)),
play: nativeImage.createFromPath(join(utils.getPath('resourcePath'), 'icons/thumbar', `${nativeTheme.shouldUseDarkColors ? 'light' : 'dark'}_play.png`)),
next: nativeImage.createFromPath(join(utils.getPath('resourcePath'), 'icons/thumbar', `${nativeTheme.shouldUseDarkColors ? 'light' : 'dark'}_next.png`)),
previous: nativeImage.createFromPath(join(utils.getPath('resourcePath'), 'icons/thumbar', `${nativeTheme.shouldUseDarkColors ? 'light' : 'dark'}_previous.png`)),
}
/*******************************************************************************************
* Private Methods
* ****************************************************************************************/
/**
* Blocks non-windows systems from running this plugin
* @private
* @decorator
*/
private static windowsOnly(_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
if (process.platform !== 'win32') {
descriptor.value = function () {
return
}
}
}
/**
* Update the thumbnail toolbar
*/
@Thumbar.windowsOnly
private updateButtons(attributes: any) {
console.log(attributes)
if (!attributes) {
return
}
const buttons = [
{
tooltip: 'Previous',
icon: this.icons.previous,
click() {
utils.playback.previous()
}
},
{
tooltip: attributes.status ? 'Pause' : 'Play',
icon: attributes.status ? this.icons.pause : this.icons.play,
click() {
utils.playback.playPause()
}
},
{
tooltip: 'Next',
icon: this.icons.next,
click() {
utils.playback.next()
}
}
];
if (!attributes.playParams || attributes.playParams.id === 'no-id-found') {
this._win.setThumbarButtons([])
} else {
this._win.setThumbarButtons(buttons);
}
}
/*******************************************************************************************
* Public Methods
* ****************************************************************************************/
/**
* Runs on plugin load (Currently run on application start)
*/
constructor(app: any, _store: any) {
this._app = app;
console.debug(`[Plugin][${this.name}] Loading Complete.`);
}
/**
* Runs on app ready
*/
onReady(win: Electron.BrowserWindow): void {
this._win = win;
console.debug(`[Plugin][${this.name}] Ready.`);
}
/**
* Runs on app stop
*/
onBeforeQuit(): void {
console.debug(`[Plugin][${this.name}] Stopped.`);
}
/**
* Runs on playback State Change
* @param attributes Music Attributes (attributes.status = current state)
*/
onPlaybackStateDidChange(attributes: object): void {
this.updateButtons(attributes)
}
/**
* Runs on song change
* @param attributes Music Attributes
*/
onNowPlayingItemDidChange(attributes: object): void {
this.updateButtons(attributes)
}
}

View file

@ -0,0 +1,244 @@
import * as WebSocket from 'ws';
/**
* 0-pad a number.
* @param {Number} number
* @param {Number} length
* @returns String
*/
const pad = (number: number, length: number) => String(number).padStart(length, '0');
/**
* Convert seconds to a time string acceptable to Rainmeter
* https://github.com/tjhrulz/WebNowPlaying-BrowserExtension/blob/master/WebNowPlaying.js#L50-L59
* @param {Number} timeInSeconds
* @returns String
*/
const convertTimeToString = (timeInSeconds: number) => {
const timeInMinutes = Math.floor(timeInSeconds / 60);
if (timeInMinutes < 60) {
return timeInMinutes + ":" + pad(Math.floor(timeInSeconds % 60), 2);
}
return Math.floor(timeInMinutes / 60) + ":" + pad(Math.floor(timeInMinutes % 60), 2) + ":" + pad(Math.floor(timeInSeconds % 60), 2);
}
export default class WebNowPlaying {
/**
* Base Plugin Details (Eventually implemented into a GUI in settings)
*/
public name: string = 'WebNowPlaying';
public description: string = 'Song info and playback control for the Rainmeter WebNowPlaying plugin.';
public version: string = '1.0.1';
public author: string = 'Zennn <me@jozen.blue>';
private _win: any;
private ws?: WebSocket;
private wsapiConn?: WebSocket;
private playerName: string = 'Cider';
constructor() {
console.debug(`[Plugin][${this.name}] Loading Complete.`);
}
/**
* Blocks non-windows systems from running this plugin
* @private
* @decorator
*/
private static windowsOnly(_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
if (process.platform !== 'win32') {
descriptor.value = () => void 0;
}
}
private sendSongInfo(attributes: any) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
const fields = ['STATE', 'TITLE', 'ARTIST', 'ALBUM', 'COVER', 'DURATION', 'POSITION', 'VOLUME', 'REPEAT', 'SHUFFLE'];
fields.forEach((field) => {
try {
let value: any = '';
switch (field) {
case 'STATE':
value = attributes.status ? 1 : 2;
break;
case 'TITLE':
value = attributes.name;
break;
case 'ARTIST':
value = attributes.artistName;
break;
case 'ALBUM':
value = attributes.albumName;
break;
case 'COVER':
value = attributes.artwork.url.replace('{w}', attributes.artwork.width).replace('{h}', attributes.artwork.height);
break;
case 'DURATION':
value = convertTimeToString(attributes.durationInMillis / 1000);
break;
case 'POSITION':
value = convertTimeToString((attributes.durationInMillis - attributes.remainingTime) / 1000);
break;
case 'VOLUME':
value = attributes.volume * 100;
break;
case 'REPEAT':
value = attributes.repeatMode;
break;
case 'SHUFFLE':
value = attributes.shuffleMode;
break;
}
this.ws?.send(`${field}:${value}`);
} catch (error) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(`Error:Error updating ${field} for ${this.playerName}`);
this.ws.send(`ErrorD:${error}`);
}
}
});
}
private fireEvent(evt: WebSocket.MessageEvent) {
if (!evt.data) return;
const data = <string>evt.data;
let value: string = '';
if (data.split(/ (.+)/).length > 1) {
value = data.split(/ (.+)/)[1];
}
const eventName = data.split(' ')[0].toLowerCase();
try {
switch (eventName) {
case 'playpause':
this._win.webContents.executeJavaScript('MusicKitInterop.playPause()').catch(console.error);
break;
case 'next':
this._win.webContents.executeJavaScript('MusicKitInterop.next()').catch(console.error);
break;
case 'previous':
this._win.webContents.executeJavaScript('MusicKitInterop.previous()').catch(console.error);
break;
case 'setposition':
this._win.webContents.executeJavaScript(`MusicKit.getInstance().seekToTime(${parseFloat(value)})`);
break;
case 'setvolume':
this._win.webContents.executeJavaScript(`MusicKit.getInstance().volume = ${parseFloat(value) / 100}`);
break;
case 'repeat':
this._win.webContents.executeJavaScript('wsapi.toggleRepeat()').catch(console.error);
break;
case 'shuffle':
this._win.webContents.executeJavaScript('wsapi.toggleShuffle()').catch(console.error);
break;
case 'togglethumbsup':
// not implemented
break;
case 'togglethumbsdown':
// not implemented
break;
case 'rating':
// not implemented
break;
}
} catch (error) {
console.debug(error);
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(`Error:Error sending event to ${this.playerName}`);
this.ws.send(`ErrorD:${error}`);
}
}
}
/**
* Runs on app ready
*/
@WebNowPlaying.windowsOnly
public onReady(win: any) {
this._win = win;
// Connect to Rainmeter plugin and retry on disconnect.
const init = () => {
try {
this.ws = new WebSocket('ws://127.0.0.1:8974/');
let retry: NodeJS.Timeout;
this.ws.onopen = () => {
console.info('[WebNowPlaying] Connected to Rainmeter');
this.ws?.send(`PLAYER:${this.playerName}`);
};
this.ws.onclose = () => {
clearTimeout(retry);
retry = setTimeout(init, 2000);
};
this.ws.onerror = () => {
clearTimeout(retry);
this.ws?.close();
};
this.ws.onmessage = this.fireEvent?.bind(this);
} catch (error) {
console.error(error);
}
};
init();
// Connect to wsapi. Only used to update progress.
try {
this.wsapiConn = new WebSocket('ws://127.0.0.1:26369/');
this.wsapiConn.onopen = () => {
console.info('[WebNowPlaying] Connected to wsapi');
};
this.wsapiConn.onmessage = (evt: WebSocket.MessageEvent) => {
const response = JSON.parse(<string>evt.data);
if (response.type === 'playbackStateUpdate') {
this.sendSongInfo(response.data);
}
};
} catch (error) {
console.error(error);
}
console.debug(`[Plugin][${this.name}] Ready.`);
}
/**
* Runs on app stop
*/
@WebNowPlaying.windowsOnly
public onBeforeQuit() {
if (this.ws) {
this.ws.send('STATE:0');
this.ws.onclose = () => void 0; // disable onclose handler first to stop it from retrying
this.ws.close();
}
if (this.wsapiConn) {
this.wsapiConn.close();
}
console.debug(`[Plugin][${this.name}] Stopped.`);
}
/**
* Runs on playback State Change
* @param attributes Music Attributes (attributes.status = current state)
*/
@WebNowPlaying.windowsOnly
public onPlaybackStateDidChange(attributes: any) {
this.sendSongInfo(attributes);
}
/**
* Runs on song change
* @param attributes Music Attributes
*/
@WebNowPlaying.windowsOnly
public onNowPlayingItemDidChange(attributes: any) {
this.sendSongInfo(attributes);
}
}