diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 67feb14a..ccd5e1ea 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,7 +18,7 @@ jobs: - name: Setup NodeJS uses: actions/setup-node@v1 with: - node-version: "12.x" + node-version: "14.x" - name: Get yarn cache directory path id: yarn-cache-dir-path diff --git a/config/defaults.js b/config/defaults.js index cbabaef6..5bb74e09 100644 --- a/config/defaults.js +++ b/config/defaults.js @@ -46,7 +46,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 c91174b7..d6451cee 100644 --- a/config/store.js +++ b/config/store.js @@ -3,8 +3,12 @@ const Store = require("electron-store"); const defaults = require("./defaults"); const migrations = { - /** Update shortcuts format from array to object */ - ">=1.12.0": (store) => { + ">=1.13.0": (store) => { + if (store.get("plugins.discord.listenAlong") === undefined) { + store.set("plugins.discord.listenAlong", true); + } + }, + ">=1.12.0": (store) => { const options = store.get("plugins.shortcuts") let updated = false; for (const optionType of ["global", "local"]) { diff --git a/index.js b/index.js index 63f139be..90096ecf 100644 --- a/index.js +++ b/index.js @@ -209,8 +209,18 @@ app.once("browser-window-created", (event, win) => { // Force user-agent "Firefox Windows" for Google OAuth to work // From https://github.com/firebase/firebase-js-sdk/issues/2478#issuecomment-571356751 // Only set on accounts.google.com, otherwise querySelectors in preload scripts fail (?) - const userAgent = - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:70.0) Gecko/20100101 Firefox/70.0"; + // Uses custom user agent to Google alert with a correct device type (https://github.com/th-ch/youtube-music/issues/327) + // User agents are from https://developers.whatismybrowser.com/useragents/explore/ + const userAgents = { + mac: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:70.0) Gecko/20100101 Firefox/70.0", + windows: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0", + linux: "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:90.0) Gecko/20100101 Firefox/90.0", + } + + const userAgent = + is.macOS() ? userAgents.mac : + is.windows() ? userAgents.windows : + userAgents.linux; win.webContents.session.webRequest.onBeforeSendHeaders((details, cb) => { details.requestHeaders["User-Agent"] = userAgent; diff --git a/package.json b/package.json index efc09a3f..fd9cff7e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "youtube-music", "productName": "YouTube Music", - "version": "1.12.2", + "version": "1.13.0", "description": "YouTube Music Desktop App - including custom plugins", "license": "MIT", "repository": "th-ch/youtube-music", @@ -60,15 +60,15 @@ "release:win": "yarn run clean && electron-builder --win -p always" }, "engines": { - "node": ">=12.20", + "node": ">=14.0.0", "npm": "Please use yarn and not npm" }, "dependencies": { - "@cliqz/adblocker-electron": "^1.22.1", + "@cliqz/adblocker-electron": "^1.22.6", "@ffmpeg/core": "^0.10.0", "@ffmpeg/ffmpeg": "^0.10.0", "YoutubeNonStop": "git://github.com/lawfx/YoutubeNonStop.git#v0.9.0", - "async-mutex": "^0.3.1", + "async-mutex": "^0.3.2", "browser-id3-writer": "^4.4.0", "custom-electron-prompt": "^1.1.0", "chokidar": "^3.5.2", @@ -80,16 +80,17 @@ "electron-localshortcut": "^3.2.1", "electron-store": "^7.0.3", "electron-unhandled": "^3.0.2", - "electron-updater": "^4.3.10", + "electron-updater": "^4.4.6", "filenamify": "^4.3.0", "md5": "^2.3.0", - "node-fetch": "^2.6.1", + "mpris-service": "^2.1.2", + "node-fetch": "^2.6.2", "node-notifier": "^9.0.1", "ytdl-core": "^4.9.1", "ytpl": "^2.2.3" }, "devDependencies": { - "electron": "^12.0.8", + "electron": "^12.1.0", "electron-builder": "^22.10.5", "electron-devtools-installer": "^3.1.1", "electron-icon-maker": "0.0.5", diff --git a/plugins/discord/back.js b/plugins/discord/back.js index c6faadd7..3c3ab5fd 100644 --- a/plugins/discord/back.js +++ b/plugins/discord/back.js @@ -1,58 +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(' || '), - }; +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/plugins/downloader/style.css b/plugins/downloader/style.css index 8119fc5e..6f29fc6b 100644 --- a/plugins/downloader/style.css +++ b/plugins/downloader/style.css @@ -6,8 +6,16 @@ cursor: pointer; } +.menu-item > .yt-simple-endpoint:hover { + background-color: var(--ytmusic-menu-item-hover-background-color); +} + .menu-icon { flex: var(--ytmusic-menu-item-icon_-_flex); margin: var(--ytmusic-menu-item-icon_-_margin); fill: var(--ytmusic-menu-item-icon_-_fill); + stroke: var(--iron-icon-stroke-color, none); + width: var(--iron-icon-width, 24px); + height: var(--iron-icon-height, 24px); + animation: var(--iron-icon_-_animation); } diff --git a/plugins/downloader/templates/download.html b/plugins/downloader/templates/download.html index d4f455d0..ede5ec02 100644 --- a/plugins/downloader/templates/download.html +++ b/plugins/downloader/templates/download.html @@ -1,5 +1,5 @@
diff --git a/plugins/downloader/youtube-dl.js b/plugins/downloader/youtube-dl.js index f0f47cec..906fc35c 100644 --- a/plugins/downloader/youtube-dl.js +++ b/plugins/downloader/youtube-dl.js @@ -15,7 +15,7 @@ const ytdl = require("ytdl-core"); const { triggerAction, triggerActionSync } = require("../utils"); const { ACTIONS, CHANNEL } = require("./actions.js"); const { getFolder, urlToJPG } = require("./utils"); -const { cleanupArtistName } = require("../../providers/song-info"); +const { cleanupName } = require("../../providers/song-info"); const { createFFmpeg } = FFmpeg; const ffmpeg = createFFmpeg({ @@ -40,7 +40,10 @@ const downloadVideoToMP3 = async ( const { videoDetails } = await ytdl.getInfo(videoUrl); const thumbnails = videoDetails?.thumbnails; metadata = { - artist: videoDetails?.media?.artist || cleanupArtistName(videoDetails?.author?.name) || "", + artist: + videoDetails?.media?.artist || + cleanupName(videoDetails?.author?.name) || + "", title: videoDetails?.media?.song || videoDetails?.title || "", imageSrcYTPL: thumbnails ? urlToJPG(thumbnails[thumbnails.length - 1].url, videoDetails?.videoId) diff --git a/plugins/lyrics-genius/back.js b/plugins/lyrics-genius/back.js new file mode 100644 index 00000000..302425d3 --- /dev/null +++ b/plugins/lyrics-genius/back.js @@ -0,0 +1,52 @@ +const { join } = require("path"); + +const { ipcMain } = require("electron"); +const is = require("electron-is"); +const fetch = require("node-fetch"); + +const { cleanupName } = require("../../providers/song-info"); +const { injectCSS } = require("../utils"); + +module.exports = async (win) => { + injectCSS(win.webContents, join(__dirname, "style.css")); + + ipcMain.on("search-genius-lyrics", async (event, extractedSongInfo) => { + const metadata = JSON.parse(extractedSongInfo); + const queryString = `${cleanupName(metadata.artist)} ${cleanupName( + metadata.title + )}`; + + let response = await fetch( + `https://genius.com/api/search/multi?per_page=5&q=${encodeURI( + queryString + )}` + ); + if (!response.ok) { + event.returnValue = null; + return; + } + + const info = await response.json(); + let url = ""; + try { + url = info.response.sections.filter( + (section) => section.type === "song" + )[0].hits[0].result.url; + } catch { + event.returnValue = null; + return; + } + + if (is.dev()) { + console.log("Fetching lyrics from Genius:", url); + } + + response = await fetch(url); + if (!response.ok) { + event.returnValue = null; + return; + } + + event.returnValue = await response.text(); + }); +}; diff --git a/plugins/lyrics-genius/front.js b/plugins/lyrics-genius/front.js new file mode 100644 index 00000000..e1354d26 --- /dev/null +++ b/plugins/lyrics-genius/front.js @@ -0,0 +1,65 @@ +const { ipcRenderer } = require("electron"); + +module.exports = () => { + ipcRenderer.on("update-song-info", (_, extractedSongInfo) => { + const lyricsTab = document.querySelector('tp-yt-paper-tab[tabindex="-1"]'); + + // Check if disabled + if (!lyricsTab || !lyricsTab.hasAttribute("disabled")) { + return; + } + + const html = ipcRenderer.sendSync( + "search-genius-lyrics", + extractedSongInfo + ); + if (!html) { + return; + } + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html; + const lyricsSelector1 = wrapper.querySelector(".lyrics"); + const lyricsSelector2 = wrapper.querySelector( + '[class^="Lyrics__Container"]' + ); + const lyrics = lyricsSelector1 + ? lyricsSelector1.innerHTML + : lyricsSelector2 + ? lyricsSelector2.innerHTML + : null; + if (!lyrics) { + return; + } + + lyricsTab.removeAttribute("disabled"); + lyricsTab.removeAttribute("aria-disabled"); + document.querySelector("tp-yt-paper-tab").onclick = () => { + lyricsTab.removeAttribute("disabled"); + lyricsTab.removeAttribute("aria-disabled"); + }; + + lyricsTab.onclick = () => { + const tabContainer = document.querySelector("ytmusic-tab-renderer"); + console.log("tabContainer", tabContainer); + const observer = new MutationObserver((_, observer) => { + const lyricsContainer = document.querySelector( + '[page-type="MUSIC_PAGE_TYPE_TRACK_LYRICS"] > ytmusic-message-renderer' + ); + if (lyricsContainer) { + lyricsContainer.innerHTML = `