// ==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.5.12
// @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] Initialized...");
const GENERAL_HANDLERS = ["play", "pause", "previoustrack", "nexttrack", "seek"];
// =========================
// === HELPFUL FUNCTIONS ===
// =========================
function onPlayerEvent(e, cb) {
const subId = getAudioPlayer().subscribers.push({ et: e, cb });
console.log(`[VKAINTEGRA] Bound ot "${e}", subscriber ID #${subId}`);
}
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() {
const originalSettingsInit = Settings.init.bind(Settings);
Settings.init = function wrappedInitSettings() {
originalSettingsInit();
initSettings();
};
}
if (cur.module === "settings") {
wrapSettings();
initSettings();
} else {
// stManager loads all the resources and we can wrap its function
const origStAdd = stManager.add.bind(stManager);
stManager.add = function wrappedStManagerAdd(...args) {
try {
if (args[0].includes("settings.js")) {
const origCb = args[1];
args[1] = function wrappedCallback() {
if (origCb) origCb();
wrapSettings();
console.log("[VKAINTEGRA] Wrapped settings initialization");
stManager.add = origStAdd;
}
}
} catch (err) {
console.error("[VKAINTEGRA] FAILED wrapped settings.js", err);
}
origStAdd(...args);
}
}
// #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] setPositionState is not implemeted!");
return () => {};
})();
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) {
const audioPosition = playlist.indexOfAudio(track);
const playlistLength = playlist.getAudiosCount() - 1;
const noPrevious = audioPosition === 0;
isLatestTrack = audioPosition === playlistLength;
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);
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);
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;
}
})();