// ==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 // @downloadURL none // ==/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 || ""; 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 ? "Если настройка включена, то, при нажатии кнопки или клавиши «Прошлый трек», вместо перехода будет осуществляться перемотка к началу трека.

Переход всегда будет осуществляться, если трек играет менее 2 секунд." : "With this setting on, clicking button or pressing “Previous track” will seek to beginning of the current track instead of switching.

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 ? "Эта настройка позволяет установить, как быстро скрипт должен убирать уведомления.

В автоматическом режиме уведомления убираются браузером или системой.

В других режимах уведомления будут убраны спустя выбранный интервал времени." : "This setting allows to set how fast script must close notifications.

In automatic mode notifications will be closed by browser or system.

In other 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 = `${USING_RU_LOCALE ? "на странице настроек" : "on settings page"}` 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; } })();