diff --git a/config/defaults.js b/config/defaults.js index f0533241..552a4995 100644 --- a/config/defaults.js +++ b/config/defaults.js @@ -45,7 +45,8 @@ const defaultConfig = { discord: { enabled: false, activityTimoutEnabled: true, // if enabled, the discord rich presence gets cleared when music paused after the time specified below - activityTimoutTime: 10 * 60 * 1000 // 10 minutes + activityTimoutTime: 10 * 60 * 1000, // 10 minutes + listenAlong: true, // add a "listen along" button to rich presence }, notifications: { enabled: false, diff --git a/config/store.js b/config/store.js index 9c0de1e4..f66c1f9a 100644 --- a/config/store.js +++ b/config/store.js @@ -3,6 +3,11 @@ const Store = require("electron-store"); const defaults = require("./defaults"); const migrations = { + ">=1.13.0": (store) => { + if (store.get("plugins.discord.listenAlong") === undefined) { + store.set("plugins.discord.listenAlong", true); + } + }, ">=1.11.0": (store) => { if (store.get("options.resumeOnStart") === undefined) { store.set("options.resumeOnStart", true); diff --git a/plugins/discord/back.js b/plugins/discord/back.js index a2eef1f5..3c3ab5fd 100644 --- a/plugins/discord/back.js +++ b/plugins/discord/back.js @@ -1,61 +1,151 @@ const Discord = require("discord-rpc"); +const { dev } = require("electron-is"); +const { dialog } = require("electron"); const registerCallback = require("../../providers/song-info"); -const rpc = new Discord.Client({ - transport: "ipc", -}); - // Application ID registered by @semvis123 const clientId = "790655993809338398"; -let clearActivity; +/** + * @typedef {Object} Info + * @property {import('discord-rpc').Client} rpc + * @property {boolean} ready + * @property {import('../../providers/song-info').SongInfo} lastSongInfo + */ +/** + * @type {Info} + */ +const info = { + rpc: null, + ready: false, + lastSongInfo: null, +}; +/** + * @type {(() => void)[]} + */ +const refreshCallbacks = []; +const resetInfo = () => { + info.rpc = null; + info.ready = false; + clearTimeout(clearActivity); + if (dev()) console.log("discord disconnected"); + refreshCallbacks.forEach(cb => cb()); +}; -module.exports = (win, {activityTimoutEnabled, activityTimoutTime}) => { - // If the page is ready, register the callback - win.once("ready-to-show", () => { - rpc.once("ready", () => { - // Register the callback - registerCallback((songInfo) => { - if (songInfo.title.length === 0 && songInfo.artist.length === 0) { - return; - } - // Song information changed, so lets update the rich presence - const activityInfo = { - details: songInfo.title, - state: songInfo.artist, - largeImageKey: "logo", - largeImageText: [ - songInfo.uploadDate, - songInfo.views.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") + " views" - ].join(' || '), - buttons: [ - { label: "Listen Along", url: songInfo.url }, - ] - }; +let window; +const connect = (showErr = false) => { + if (info.rpc) { + if (dev()) + console.log('Attempted to connect with active RPC object'); + return; + } - if (songInfo.isPaused) { - // Add an idle icon to show that the song is paused - activityInfo.smallImageKey = "idle"; - activityInfo.smallImageText = "idle/paused"; - // Set start the timer so the activity gets cleared after a while if enabled - if (activityTimoutEnabled) - clearActivity = setTimeout(()=>rpc.clearActivity(), activityTimoutTime||10000); - } else { - // stop the clear activity timout - clearTimeout(clearActivity); - // Add the start and end time of the song - const songStartTime = Date.now() - songInfo.elapsedSeconds * 1000; - activityInfo.startTimestamp = songStartTime; - activityInfo.endTimestamp = - songStartTime + songInfo.songDuration * 1000; - } + info.rpc = new Discord.Client({ + transport: "ipc", + }); + info.ready = false; - rpc.setActivity(activityInfo); - }); - }); + info.rpc.once("connected", () => { + if (dev()) console.log("discord connected"); + refreshCallbacks.forEach(cb => cb()); + }); + info.rpc.once("ready", () => { + info.ready = true; + if (info.lastSongInfo) updateActivity(info.lastSongInfo) + }); + info.rpc.once("disconnected", resetInfo); - // Startup the rpc client - rpc.login({ clientId }).catch(console.error); + // Startup the rpc client + info.rpc.login({ clientId }).catch(err => { + resetInfo(); + if (dev()) console.error(err); + if (showErr) dialog.showMessageBox(window, { title: 'Connection failed', message: err.message || String(err), type: 'error' }); }); }; + +let clearActivity; +/** + * @type {import('../../providers/song-info').songInfoCallback} + */ +let updateActivity; + +module.exports = (win, {activityTimoutEnabled, activityTimoutTime, listenAlong}) => { + window = win; + // We get multiple events + // Next song: PAUSE(n), PAUSE(n+1), PLAY(n+1) + // Skip time: PAUSE(N), PLAY(N) + updateActivity = songInfo => { + if (songInfo.title.length === 0 && songInfo.artist.length === 0) { + return; + } + info.lastSongInfo = songInfo; + + // stop the clear activity timout + clearTimeout(clearActivity); + + // stop early if discord connection is not ready + // do this after clearTimeout to avoid unexpected clears + if (!info.rpc || !info.ready) { + return; + } + + // clear directly if timeout is 0 + if (songInfo.isPaused && activityTimoutEnabled && activityTimoutTime === 0) { + info.rpc.clearActivity().catch(console.error); + return; + } + + // Song information changed, so lets update the rich presence + // @see https://discord.com/developers/docs/topics/gateway#activity-object + // not all options are transfered through https://github.com/discordjs/RPC/blob/6f83d8d812c87cb7ae22064acd132600407d7d05/src/client.js#L518-530 + const activityInfo = { + type: 2, // Listening, addressed in https://github.com/discordjs/RPC/pull/149 + details: songInfo.title, + state: songInfo.artist, + largeImageKey: "logo", + largeImageText: [ + songInfo.uploadDate, + songInfo.views.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") + " views", + ].join(' || '), + buttons: listenAlong ? [ + { label: "Listen Along", url: songInfo.url }, + ] : undefined, + }; + + if (songInfo.isPaused) { + // Add an idle icon to show that the song is paused + activityInfo.smallImageKey = "idle"; + activityInfo.smallImageText = "idle/paused"; + // Set start the timer so the activity gets cleared after a while if enabled + if (activityTimoutEnabled) + clearActivity = setTimeout(() => info.rpc.clearActivity().catch(console.error), activityTimoutTime ?? 10000); + } else { + // Add the start and end time of the song + const songStartTime = Date.now() - songInfo.elapsedSeconds * 1000; + activityInfo.startTimestamp = songStartTime; + activityInfo.endTimestamp = + songStartTime + songInfo.songDuration * 1000; + } + + info.rpc.setActivity(activityInfo).catch(console.error); + }; + + // If the page is ready, register the callback + win.once("ready-to-show", () => { + registerCallback(updateActivity); + connect(); + }); + win.on("close", () => module.exports.clear()); +}; + +module.exports.clear = () => { + if (info.rpc) info.rpc.clearActivity(); + clearTimeout(clearActivity); +}; +module.exports.connect = connect; +module.exports.registerRefresh = (cb) => refreshCallbacks.push(cb); +/** + * @type {Info} + */ +module.exports.info = Object.defineProperties({}, Object.keys(info).reduce((o, k) => ({ ...o, [k]: { enumerable: true, get: () => info[k] } }), {})); diff --git a/plugins/discord/menu.js b/plugins/discord/menu.js new file mode 100644 index 00000000..9750dabe --- /dev/null +++ b/plugins/discord/menu.js @@ -0,0 +1,47 @@ +const { setOptions } = require("../../config/plugins"); +const { edit } = require("../../config"); +const { clear, info, connect, registerRefresh } = require("./back"); + +let hasRegisterred = false; + +module.exports = (win, options, refreshMenu) => { + if (!hasRegisterred) { + registerRefresh(refreshMenu); + hasRegisterred = true; + } + + return [ + { + label: info.rpc !== null ? "Connected" : "Reconnect", + enabled: info.rpc === null, + click: connect, + }, + { + label: "Clear activity", + click: clear, + }, + { + label: "Clear activity after timeout", + type: "checkbox", + checked: options.activityTimoutEnabled, + click: (item) => { + options.activityTimoutEnabled = item.checked; + setOptions('discord', options); + }, + }, + { + label: "Listen Along", + type: "checkbox", + checked: options.listenAlong, + click: (item) => { + options.listenAlong = item.checked; + setOptions('discord', options); + }, + }, + { + label: "Set timeout time in config", + // open config.json + click: edit, + }, + ]; +}; diff --git a/providers/song-info.js b/providers/song-info.js index 0ef0f868..6e5a119c 100644 --- a/providers/song-info.js +++ b/providers/song-info.js @@ -40,6 +40,9 @@ const getArtist = async (win) => { } // Fill songInfo with empty values +/** + * @typedef {songInfo} SongInfo + */ const songInfo = { title: "", artist: "", @@ -72,6 +75,14 @@ const handleData = async (responseText, win) => { const callbacks = []; // This function will allow plugins to register callback that will be triggered when data changes +/** + * @callback songInfoCallback + * @param {songInfo} songInfo + * @returns {void} + */ +/** + * @param {songInfoCallback} callback + */ const registerCallback = (callback) => { callbacks.push(callback); };