VK Audio Integration

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;
    }
})();