Integrates VK.com audio player with MediaSession API
// ==UserScript== // @name VK Audio Integration // @name:ru Аудио интеграция VK // @description Integrates VK.com audio player with MediaSession API // @description:ru Интегрирует аудиоплеер ВКонтакте с API MediaSession // @author Sasha Sorokin // @version 1.7.0 // @license MIT https://raw.githubusercontent.com/Sasha-Sorokin/vkaintegra/master/LICENSE // @namespace https://github.com/Sasha-Sorokin/vkaintegra // @homepage https://github.com/Sasha-Sorokin/vkaintegra // @supportURL https://github.com/Sasha-Sorokin/vkaintegra/issues // @grant GM.notification // @grant GM_notification // @grant GM.setValue // @grant GM_setValue // @grant GM.getValue // @grant GM_getValue // @include https://vk.com/* // @run-at document-end // @noframes // ==/UserScript== (async () => { "use strict"; console.log("[VKAINTEGRA] Initializing..."); const GENERAL_HANDLERS = ["play", "pause", "previoustrack", "nexttrack", "seek"]; // ========================= // === HELPFUL FUNCTIONS === // ========================= function onPlayerEvent(eventName, callback) { const callbackName = callback.name || "<anonymous>"; const subscriber = { et: eventName, cb: function safeCallback(...args) { try { callback(...args); } catch (err) { console.error(`[VKAINTEGRA] (!) Player callback ${callbackName} for event ${eventName} has failed:`, err); } } }; const subscriberId = getAudioPlayer().subscribers.push(subscriber); console.log(`[VKAINTEGRA] Bound callback ${callbackName} for "${eventName}", subscriber ID #${subscriberId}`); } function htmlDecode(input) { const doc = new DOMParser().parseFromString(input, "text/html"); return doc.documentElement.textContent; } // 14 artworks function extractArtworks(audio) { const artworks = [...new Set(audio[14].split(","))]; for (let i = 0, l = artworks.length; i < l; i++) { artworks[i] = { src: artworks[i], sizes: "80x80" }; } return artworks; } // 3 title // 4 artist // 16 remix function extractVKMetadata(audio) { let title = htmlDecode(audio[3]); const remixType = audio[16]; if (remixType !== "") title += ` (${htmlDecode(remixType)})`; return { artist: htmlDecode(audio[4]), title, artwork: extractArtworks(audio) }; } function extractTimes(audio) { // 15 durations return audio[15]; } const USING_RU_LOCALE = (function isUsingRuLocale() { return [0, 1, 100, 114, 777].includes(langConfig.id); })(); function insertBefore(referenceNode, newNode) { referenceNode.parentNode.insertBefore(newNode, referenceNode); } // from underscore.js function debounce(func, wait, immediate) { let timeout; return function() { const context = this, args = arguments; const later = function() { timeout = null; if (!immediate) func.apply(context, args); }; const callNow = immediate && !timeout; clearTimeout(timeout); timeout = setTimeout(later, wait); if (callNow) func.apply(context, args); }; }; // ==================== // === SETTINGS === // ==================== // BUG-5: GM can be different and we must be catchy const settings = { setValue: (() => { try { return GM && GM.setValue; } catch { return GM_setValue; } })(), getValue: (() => { try { return GM && GM.getValue; } catch { return GM_getValue; } })() }; /** * Are notifications enabled */ let notificationsEnabled; /** * Are notifications disposed by script * @default null Notifications are not disposed */ let notificationsDispose; /** * Does single press on Previous key seeks to beginning? */ let previousSeeking; /** * Should be "next track" button be actived on latest track in playlist? */ let lastNext; // Load all the settings await (async () => { notificationsEnabled = await settings.getValue("notificationsEnabled", false); notificationsDispose = await settings.getValue("notificationsDispose", "3s"); previousSeeking = await settings.getValue("previousSeeking", false); lastNext = await settings.getValue("lastNext", true); })(); function saveSettings() { settings.setValue("notificationsEnabled", notificationsEnabled); settings.setValue("notificationsDispose", notificationsDispose); settings.setValue("previousSeeking", previousSeeking); settings.setValue("lastNext", lastNext); } // ========================= // === SETTINGS CONTROLS === // ========================= { // #region Elements functions function appendTo(elem, children) { for (let i = 0, l = children.length; i < l; i++) { const child = children[i]; if (typeof child === "function") child(elem); else elem.appendChild(child); } } function inlineMenuValueText(values, value) { for (let i = 0, l = values.length; i < l; i++) { const item = values[i]; if (item[0] === value) return item[1]; } } function createInlineMenu(id, currentValue, values, onSelect) { const div = document.createElement("div"); div.id = id; div.classList.add(id); const selectedValue = document.createElement("div"); selectedValue.classList.add("idd_selected_value"); selectedValue.setAttribute("tabIndex", 0); selectedValue.setAttribute("role", "link"); selectedValue.innerText = inlineMenuValueText(values, currentValue); div.appendChild(selectedValue); const input = document.createElement("input"); input.id = `${id}_input`; input.setAttribute("type", "hidden"); input.setAttribute("name", id); input.value = currentValue; div.appendChild(input); return function mount(parent) { parent.appendChild(div); const dropdown = new InlineDropdown(div, { items: values, selected: currentValue, onSelect }); mount.component = dropdown; } } function createCheckbox(id, text, isChecked, onChange) { const checkbox = document.createElement("input"); checkbox.classList.add("blind_label"); checkbox.setAttribute("type", "checkbox"); checkbox.checked = isChecked; checkbox.id = id; checkbox.addEventListener("change", onChange); const label = document.createElement("label"); label.setAttribute("for", id); label.innerText = text; return [checkbox, label]; } function createSettingsNarrowRow(children) { const div = document.createElement("div"); div.classList.add("settings_narrow_row"); appendTo(div, children); return div; } function createSettingsLine(labelText, id, children) { const div = document.createElement("div"); div.id = id; div.classList.add("settings_line"); const label = document.createElement("div"); label.classList.add("settings_label"); label.innerText = labelText; div.appendChild(label); const inner = document.createElement("div"); inner.classList.add("settings_labeled_text"); appendTo(inner, children); div.appendChild(inner); return div; } function createHint(text) { const hint = document.createElement("span"); hint.classList.add("hint_icon"); hint.addEventListener("mouseover", function showHint() { showTooltip(this, { text, dir: "auto", shift: [22, 10], slide: 15, className: "settings_tt" }) }); return hint; } function cid(id) { return `vkaintegra_${id}`; } const initNotifyValues = [ // [value, [russian, english]] ["auto", ["автоматически", "automatically"]], ["3s", ["спустя 3 секунды", "3 seconds after"]], ["5s", ["спустя 5 секунд", "5 seconds after"]], ]; function getNotifyDisposeValues() { const values = []; for (let i = 0, l = initNotifyValues.length; i < l; i++) { const item = initNotifyValues[i]; values.push([item[0], item[1][USING_RU_LOCALE ? 0 : 1]]); } return values; } function disableElement(element) { element.style.opacity = "0.5"; element.style["pointer-events"] = "none"; } function enableElement(element) { element.style.opacity = ""; element.style["pointer-events"] = ""; } function bindTooltip(elem, text) { elem.addEventListener("mouseover", function showLabelTooltip() { showTooltip(this, { shift: [-20, 8, 8], dir: "auto", text: text, slide: 15, className: 'settings_tt', hasover: 1 }); }); } // #endregion // ================ // === EVENTS === // ================ async function saveSettingsInteractive() { saveSettings(); unsafeWindow.uiPageBlock && uiPageBlock.showSaved("vkaintegra"); } function previousSeekingChanged(e) { previousSeeking = e.target.checked; saveSettingsInteractive(); } function lastNextChanged(e) { lastNext = e.target.value; try { // We may need to refresh the controls to apply changes const player = getAudioPlayer(); const { _currentAudio: audio } = player; if (audio != null) { console.log("[VKAINTEGRA] Refreshing controls due to lastNext change"); onStop(); onStart(); onTrackChange(player._currentAudio, false); if (!player._isPlaying) onPause(); } } catch (err) { console.error("[VKAINTEGRA] Failed to refresh controls", err); } saveSettingsInteractive(); } let notificationsChangeLock = false; async function notificationsChanged(e) { if (notificationsChangeLock) return true; let shouldSave = true; if (e.target.checked) { if (Notification.permission !== "granted") { // locking element e.target.disabled = true; disableElement(e.target.parentElement); notificationsChangeLock = true; const status = await Notification.requestPermission(); if (status !== "granted") { showDoneBox( USING_RU_LOCALE ? "Кажется вы отклонили запрос, либо они блокируются браузером." : "It seems you have denied request, or they're disabled in the browser." ); e.target.checked = false; shouldSave = false; } e.target.disabled = false; enableElement(e.target.parentElement); notificationsChangeLock = false; } notificationsEnabled = Notification.permission === "granted"; } else { notificationsEnabled = false; } if (notificationsEnabled) { enableElement(settingsPanel.notifyDisposeSelect.component.getElement().parentNode); } else { disableElement(settingsPanel.notifyDisposeSelect.component.getElement().parentNode); } if (shouldSave) saveSettingsInteractive(); } function notifyDisposeSelected(val) { notificationsDispose = val; saveSettingsInteractive(); } // ============================= // === SETTINGS PANEL ITSELF === // ============================= let settingsPanel = Object.create(null); async function getSettingsLine() { // #region Panel initialization if (!settingsPanel.previousSeekingCheckbox) { const [,label] = settingsPanel.previousSeekingCheckbox = createCheckbox( cid("previous_seeking"), USING_RU_LOCALE ? "«Прошлый трек» перематывает в начало" : "“Previous track” seeking to beginning", previousSeeking, previousSeekingChanged ); const tooltipText = USING_RU_LOCALE ? "Если настройка включена, то, при нажатии кнопки или клавиши «Прошлый трек», вместо перехода будет осуществляться перемотка к началу трека.<br><br>Переход всегда будет осуществляться, если трек играет менее 2 секунд." : "With this setting on, clicking button or pressing “Previous track” will seek to beginning of the current track instead of switching.<br><br>Switching will always happen if track is playing for less than 2 seconds."; bindTooltip(label, tooltipText); } if (!settingsPanel.lastNextCheckbox) { const [,label] = settingsPanel.lastNextCheckbox = createCheckbox( cid("last_next"), USING_RU_LOCALE ? "Не отключать «Следующий трек» в конце плейлиста" : "Do not disable “Next track” at last song in playlist", lastNext, lastNextChanged ); const tooltipText = USING_RU_LOCALE ? "Включение этой настройки убирает отключение кнопки «Следующий трек» при проигрывании последнего трека в плейлисте. Нажатие этой кнопки остановит воспроизведение и переключится на первый трек в плейлисте." : "Enabling this option avoids disabling of “Next track” button when playing last track in playlist. Pressing this button stops playing and switches to first track in playlist." bindTooltip(label, tooltipText); } if (!settingsPanel.notificationsCheckbox) { settingsPanel.notificationsCheckbox = createCheckbox( cid("notifications"), USING_RU_LOCALE ? "Включить уведомления" : "Enable notifications", notificationsEnabled, notificationsChanged ); } if (!settingsPanel.notifyDisposeSelect) { settingsPanel.notifyDisposeSelect = createInlineMenu( cid("notifications_dispose"), notificationsDispose, getNotifyDisposeValues(), notifyDisposeSelected ); } if (!settingsPanel.panel) { const CLOSE_NOTIFS_TEXT = document.createTextNode( USING_RU_LOCALE ? "Убирать уведомления " : "Close notifications " ); const DISPOSE_HINT = createHint( USING_RU_LOCALE ? "Эта настройка позволяет установить, как быстро скрипт должен убирать уведомления.<br><br>В <b>автоматическом</b> режиме уведомления убираются браузером или системой.<br><br>В <b>других</b> режимах уведомления будут убраны спустя выбранный интервал времени." : "This setting allows to set how fast script must close notifications.<br><br>In <b>automatic</b> mode notifications will be closed by browser or system.<br><br>In <b>other</b> modes notifications will be closed after selected interval." ); settingsPanel.panel = createSettingsLine("VK Audio Integration", "vkaintegra", [ createSettingsNarrowRow(settingsPanel.previousSeekingCheckbox), createSettingsNarrowRow(settingsPanel.lastNextCheckbox), createSettingsNarrowRow(settingsPanel.notificationsCheckbox), createSettingsNarrowRow([CLOSE_NOTIFS_TEXT, settingsPanel.notifyDisposeSelect, DISPOSE_HINT]) ]); if (!notificationsEnabled) { disableElement(settingsPanel.notifyDisposeSelect.component.getElement().parentNode); } } // #endregion settingsPanel.previousSeekingCheckbox[0].toggled = previousSeeking; settingsPanel.notificationsCheckbox[0].toggled = notificationsEnabled; settingsPanel.notifyDisposeSelect.component.select(notificationsDispose, true); return settingsPanel.panel; } async function initSettings() { const pwdChange = document.querySelector("div.settings_line#chgpass"); insertBefore(pwdChange, await getSettingsLine()); } // ========================= // === SETTINGS WRAPPING === // ========================= // #region Settings Wrapping function wrapSettings(settings) { const origSettingsInit = settings.init.bind(Settings); settings.init = function wrappedInitSettings() { origSettingsInit(); initSettings(); }; } if (cur.module === "settings") { wrapSettings(Settings); initSettings(); } else { let origSettings; Object.defineProperty(unsafeWindow, "Settings", { get() { return origSettings; }, set(value) { origSettings = value; wrapSettings(value); }, }); } // #endregion } // ===================== // === NOTIFICATIONS === // ===================== if (notificationsEnabled && Notification.permission !== "granted") { const SETTINGS_LINK = `<a href=\"/settings\" onclick=\"nav.go(this, event, {noback: !0}))\">${USING_RU_LOCALE ? "на странице настроек" : "on settings page"}</a>` showDoneBox( USING_RU_LOCALE ? `С момента прошлой активации уведомлений от VK Audio Integration разрешения на отправку этих самых уведомлений больше нет. Включить их обратно можно ${SETTINGS_LINK}.` : `Since last activation of notifications from VK Audio Integration, there is no more permission to send those notifications. You can re-enable them ${SETTINGS_LINK}.` ); notificationsEnabled = false; saveSettings(); } const UNKNOWN_AUDIO_ICON = { SMALL: "https://i.imgur.com/tTGovqM.png", LARGE: "https://i.imgur.com/EbP2xGC.png" }; let currentNotificationTimer = undefined; const DISPOSE_OPTIONS = { "3s": 3000, "5s": 5000 }; function showNotification(trackMetadata, actualityCallback, unknownAlbum) { if (!notificationsEnabled) return; let icon = trackMetadata.artwork[0].src; if (icon === UNKNOWN_AUDIO_ICON.LARGE) { icon = UNKNOWN_AUDIO_ICON.SMALL; } const albumLine = unknownAlbum ? "VK" : `${trackMetadata.album} · VK`; const notification = new Notification(trackMetadata.title, { body: `${trackMetadata.artist}\n${albumLine}`, silent: true, icon, tag: "vk-nowplaying" }); if (!actualityCallback()) { notification.close(); } else if (notificationsDispose !== "auto") { if (currentNotificationTimer) clearTimeout(currentNotificationTimer); setTimeout(() => { notification.close(); currentNotificationTimer = null; }, DISPOSE_OPTIONS[notificationsDispose]); } } const notificationDebounce = debounce(showNotification, 500); // ===================== // === PLAYER EVENTS === // ===================== const setPositionState = navigator.mediaSession.setPositionState ? navigator.mediaSession.setPositionState : (() => { console.log("[VKAINTEGRA] Browser support: setPositionState is not implemeted."); return undefined; })(); let isStarted = false; function onStart() { isStarted = true; bindGeneralHandlers(); navigator.mediaSession.playbackState = "playing"; } onPlayerEvent("start", onStart); function previousTrack(player) { // FEAT-1: Rewind to start instead of playing previous if (previousSeeking && player.stats.currentPosition > 2) { player.seekToTime(0); } else { player.playPrev(); } } let isLatestTrack = false; function updateControls(player, playlist, track) { let noPrevious; if (playlist) { const audioPosition = playlist.indexOfAudio(track); const playlistLength = playlist.getAudiosCount() - 1; noPrevious = audioPosition === 0; isLatestTrack = audioPosition === playlistLength; } else { noPrevious = true; isLatestTrack = true; } if (!lastNext) { if (isLatestTrack) resetHandlers("nexttrack"); else bindHandler("nexttrack", () => player.playNext()); } if (noPrevious) resetHandlers("previoustrack"); else bindHandler("previoustrack", () => previousTrack(player)); } function onPlaylistChange() { // BUG-2: Shuffle does not fire any events const playlist = getAudioPlayer()._currentPlaylist; if (playlist == null) return; const originalShuffle = playlist.shuffle.bind(playlist); playlist.shuffle = (...args) => { console.log("[VKAINTEGRA] Caught a shuffle attempt!"); const player = getAudioPlayer(); originalShuffle(...args); updateControls(player, player._currentPlaylist, player._currentAudio); }; } onPlayerEvent("plchange", onPlaylistChange); function onTrackChange(track, notification = true) { // BUG-7: Sometimes VK tells us it has no current track if (!track) return onStop(); const trackMetadata = extractVKMetadata(track); const player = getAudioPlayer(); // Use current playlist name as the album title let playlist = player._currentPlaylist; // BUG-1: Sometimes we going to deal with referenced playlists if (playlist._ref) { playlist = playlist._ref; // But it's good to us to take a bigger cover image // BUG-3: If that's an official album, of course if (playlist._isOfficial && playlist._coverUrl !== "") { trackMetadata.artwork = [{ src: playlist._coverUrl, sizes: "300x300" }]; } } // BUG-9: playlist titles can be empty for some reason const playlistTitle = htmlDecode(playlist._title); let unknownPlaylist = false; if (playlistTitle === "") { playlistTitle = USING_RU_LOCALE ? "(неизвестно)" : "(unknown)"; unknownPlaylist = true; } trackMetadata.album = playlistTitle; // BUG-10: chrome sets url of the current page if artwork == "", // so let's use unknown icon as we did with notifications for // every empty artwork in the array { const artworks = trackMetadata.artwork; for (let i = 0, l = artworks.length; i < l; i++) { const artwork = artworks[i]; if (artwork.src === "") { artwork.src = UNKNOWN_AUDIO_ICON.LARGE; artwork.sizes = "450x450"; }; } } // Prepare the media session navigator.mediaSession.metadata = new MediaMetadata(trackMetadata); if (setPositionState != null) { setPositionState({ duration: extractTimes(track).duration }); } navigator.mediaSession.playbackState = "playing"; updateControls(player, playlist, track); if (isStarted && notification) { notificationDebounce( trackMetadata, () => player._currentAudio[0] === track[0], unknownPlaylist ); } } onPlayerEvent("curr", onTrackChange); if (setPositionState != null) { onPlayerEvent("progress", function onProgress(_progress, duration, position) { setPositionState({ duration, playbackRate: 1, position }); }); onPlayerEvent("seek", function onSeek(track) { setPositionState({ duration: extractTimes(track).duration, playbackRate: 1, position: getAudioPlayer()._listenedTime }); }); } function onPause() { navigator.mediaSession.playbackState = "paused"; } onPlayerEvent("pause", onPause); function onStop() { console.log("[VKAINTEGRA] Player stopped. Reset state and unbind handlers"); navigator.mediaSession.playbackState = "none"; navigator.mediaSession.metadata = undefined; resetHandlers(GENERAL_HANDLERS); isStarted = false; } onPlayerEvent("stop", onStop); // =================== // === POST EVENTS === // =================== onPlaylistChange(); // ========================== // === ALL ABOUT HANDLERS === // ========================== let generalHandlersBound = false; const handlerStates = Object.create(null); // BUG-4: Chrome does not suppert "seek" and throws error function setActionHandlerSafe(name, handler) { try { navigator.mediaSession.setActionHandler(name, handler); } catch { console.warn(`[VKAINTEGRA] Failed to setActionHandler "${name}", it may not supported in this browser`); } } function bindHandler(name, handler) { if (handlerStates[name]) return; setActionHandlerSafe(name, handler); handlerStates[name] = true; } function resetHandlers(names) { if (names == null) throw new Error("Cannot reset no handlers"); if (!Array.isArray(names)) names = [names]; for (let i = 0, l = names.length; i < l; i++) { const name = names[i]; if (!handlerStates[name]) continue; setActionHandlerSafe(name, null); handlerStates[name] = undefined; } if (names === GENERAL_HANDLERS) generalHandlersBound = false; } function bindGeneralHandlers() { if (generalHandlersBound) return; const player = getAudioPlayer(); bindHandler("play", () => player.play()); bindHandler("pause", () => player.pause()); bindHandler("seek", ({ seekTime }) => player.seekToTime(seekTime)); if (lastNext) { bindHandler("nexttrack", () => { // BUG-8: playNext() after latest track not firing stop or pause let stopAfter = false; if (isLatestTrack && !ap.isRepeatAll()) stopAfter = true; player.playNext(); if (stopAfter) player.stop(); }); } generalHandlersBound = true; } })();