Iwara Enhancement

Multiple UI enhancements for better experience.

As of 2021-09-01. See the latest version.

// ==UserScript==
// @name         Iwara Enhancement
// @name:zh-CN   Iwara增强
// @namespace    https://github.com/guansss/userscripts
// @version      0.6
// @description  Multiple UI enhancements for better experience.
// @description:zh-CN 多种增强体验的界面优化
// @author       guansss
// @match        *://*.iwara.tv/*
// @require      https://cdn.jsdelivr.net/npm/video.js@7.10.1/dist/video.min.js#sha256=9HM07Of11yw3TL/m0BxP9pw08qXmG/xOTDc1d3sp2Wo=
// @resource     vjs-css https://cdn.jsdelivr.net/npm/video.js@7.10.1/dist/video-js.min.css#sha256=/fXfq3QrnWyMYmF0zX6ImdI1DTraNCAq1vPofa2rs2w=
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_getResourceText
// @grant        GM_download
// @grant        GM_info
// @grant        unsafeWindow
// @run-at       document-start
// ==/UserScript==

const VIDEOJS_THUMB_PLUGIN = 'https://cdn.jsdelivr.net/npm/videojs-thumbnail-sprite@0.1.1/dist/index.min.js';

// the storage keys
const KEY_VOLUME = 'volume';
const KEY_FILENAME = 'filename';
const KEY_DARK_MODE = 'dark';
const KEY_LIKE_RATES = 'like_rates';

const DEFAULT_FILENAME_TEMPLATE = 'DATE TITLE - AUTHOR (ID)';
let filenameTemplate = GM_getValue(KEY_FILENAME, DEFAULT_FILENAME_TEMPLATE);

function main() {
    'use strict';

    const ready = new Promise(resolve => document.addEventListener('DOMContentLoaded', resolve));

    // jQuery is available only when ready
    ready.then(() => window.$ = unsafeWindow.$ = unsafeWindow.jQuery);

    {
        general();
        enhanceList();

        if (location.pathname.match(/(videos|images)\//)) {
            prettifyContentPage();

            if (location.pathname.includes('videos')) {
                enhanceVideo();
                setupThumbnails();
                setupAutoDownload();
            }
        } else if (location.pathname.includes('search')) {
            enhanceSearch();
        }
    }

    async function general() {
        GM_addStyle(GLOBAL_STYLES);

        // dark mode
        if (GM_getValue(KEY_DARK_MODE, false)) {
            document.documentElement.classList.add('dark');
        }

        await ready;

        // remove R18 warning
        $('#r18-warning').remove();

        $('<a class="btn btn-info btn-sm" title="Dark mode"><i class="glyphicon glyphicon-eye-open"></i></a>')
            .insertAfter('#user-links .search-link')
            .on('click', () => {
                document.documentElement.classList.toggle('dark');
                GM_setValue(KEY_DARK_MODE, document.documentElement.classList.contains('dark'));
            });
    }

    async function enhanceList() {
        await ready;

        const likeRatesEnabled = GM_getValue(KEY_LIKE_RATES, true);

        $('#block-mainblocks-sub-menu .list-inline')
            .after(`<label for="check-like-rates" class="checkbox"><input type="checkbox" id="check-like-rates" ${likeRatesEnabled ? 'checked' : ''}>Display like rates</label>`);

        $('#check-like-rates').change(function() {
            GM_setValue(KEY_LIKE_RATES, this.checked);
            enableLikeRates(this.checked);
        });

        function enableLikeRates(enabled) {
            if (enabled) {
                $('body').removeClass('hide-like-rates');
            } else {
                $('body').addClass('hide-like-rates');
            }
        }

        enableLikeRates(likeRatesEnabled);

        // iterates over video/image items in the page
        $('.view-content .views-column, .view-content .col-sm-3').each(function() {
            const thiz = $(this);

            if (thiz.children(':first-child').is('.node-teaser, .node-sidebar_teaser')) {
                const url = thiz.find('.title a').attr('href');

                // set up like rates and highlights

                const viewsIcon = thiz.find('.likes-icon.left-icon');
                const likesIcon = thiz.find('.likes-icon.right-icon');

                // ensure the likes icon exists because it will be missing if the likes are 0
                if (likesIcon.length) {
                    let [views, likes] = [viewsIcon, likesIcon].map(icon => {
                        let value = icon.html().replace(/<i.*<\/i>/m, '').trim();

                        value = value.includes('k') ? value.slice(0, -1) * 1000 : value;

                        return +value;
                    });

                    const likeRatePercent = views === 0 ? 0 : Math.round(1000 * likes / views) / 10;

                    viewsIcon.after(`<div class="like-rate left-icon">${likeRatePercent}%</div>`);

                    if (likeRatePercent >= 4) {
                        thiz.addClass('highlight');
                    }
                }

                // differentiate images from videos in subscriptions page
                // by adding an "image" icon on the image item that's not denoted by a "multiple" icon

                if (location.href.includes('subscriptions') && !thiz.find('.multiple-icon').length) {
                    if (url.startsWith('/images')) {
                        viewsIcon.before('<div class="left-icon"><i class="glyphicon glyphicon-picture"></i></div>');
                    }
                }

                // fix broken preview images

                const placeholder = `<a href="${url}" class="preview-placeholder"><div>NO PREVIEW</div></a>`;
                const teaserContainer = thiz.find('.field-type-video .field-item');

                if (!teaserContainer.children().length) {
                    teaserContainer.append(placeholder);
                } else {
                    teaserContainer.find('img').error(function() {
                        teaserContainer.empty().append(placeholder);
                    });
                }
            }
        });
    }

    async function enhanceVideo() {
        // load CSS of the new videojs
        GM_addStyle(GM_getResourceText('vjs-css'));

        // patch the player.on() to return itself to support method chaining, which is no longer supported in the new version
        const Player = videojs.getComponent('Player');
        const readyFn = Player.prototype.ready;

        Player.prototype.ready = function() {
            const onFn = this.on;

            if (onFn && !onFn.patched) {
                this.on = function() {
                    onFn.apply(this, arguments);
                    return this;
                };
                this.on.patched = true;
            }
            return readyFn.apply(this, arguments);
        };

        // copy the plugins if the old videojs has already been loaded before this userscript is injected to the page
        if (unsafeWindow.videojs) {
            const oldVideojs = unsafeWindow.videojs;

            unsafeWindow.videojs = videojs;

            // registered plugins can be found by checking the <script> tags in page HTML
            const registeredPlugins = ['hotkeys', 'persistvolume', 'loopbutton', 'videoJsResolutionSwitcher'];

            // copy plugins to the new videojs
            for (const plugin of registeredPlugins) {
                const pluginMethod = oldVideojs.getComponent('Player').prototype[plugin];

                if (typeof pluginMethod === 'function') {
                    videojs.registerPlugin(plugin, pluginMethod);
                }
            }
        }
        // otherwise, prevent the old videojs from loading
        else {
            unsafeWindow.videojs = videojs;

            let scriptExists = false;

            // there's a chance that the <script> tag of videojs has been inserted to the page,
            // I'm not quite sure though
            for (const element of document.head.children) {
                if (element.src && element.src.includes('video-js/video.js')) {
                    element.remove();
                    scriptExists = true;
                    break;
                }
            }

            if (!scriptExists) {
                // immediately remove the <script> tag once it's inserted to the HTML
                new MutationObserver((mutationsList, observer) => {
                    mutationsList.forEach(mutation => {
                        if (mutation.type === 'childList') {
                            for (const node of mutation.addedNodes) {
                                if (node && node.src && node.src.includes('video-js/video.js')) {
                                    observer.disconnect();
                                    node.remove();
                                }
                            }
                        }
                    });
                }).observe(document.head, { childList: true });
            }
        }

        // recover the volume in incognito mode
        if (localStorage['-volume'] === undefined) {
            localStorage['-volume'] = GM_getValue(KEY_VOLUME, 0.5);
        }

        await ready;

        // remove CSS of the old videojs
        for (const node of document.head.childNodes) {
            if (node && node.tagName === 'STYLE' && node.innerHTML.includes('video-js')) {
                node.innerHTML = node.innerHTML.replace(/.+?video-js\.min\.css.+/, '');
            }
        }

        const player = await repeatUntil(() => videojs.getPlayers()['video-player']);

        player
            .on('fullscreenchange', () => {
                $('#video-player').focus();
            })
            .on('volumechange', () => {
                // save volume when changed
                GM_setValue(KEY_VOLUME, player.volume() || 0.5);
            });
    }

    async function setupThumbnails() {
        await ready;

        const player = await repeatUntil(() => videojs.getPlayers()['video-player']);

        // e.g. //i.iwara.tv/sites/default/files/videos/thumbnails/1404656/thumbnail-1404656_0001.jpg
        const previewURL = player.poster();

        if (previewURL && previewURL.includes('thumbnail')) {
            // the thumbnail plugin is a commonjs module so we have to define these stuff for it
            unsafeWindow.exports = {};
            unsafeWindow.require = module => module === 'video.js' ? videojs : undefined;

            // load the thumbnail plugin
            await new Promise(resolve => {
                const script = document.createElement('script');
                script.onload = resolve;
                script.src = VIDEOJS_THUMB_PLUGIN;
                document.head.appendChild(script);
            });

            // duration and dimensions are included in the meta data
            player.on('loadedmetadata', () => {
                const division = 16;
                const interval = player.duration() / division;

                const width = 180;
                const height = width * player.videoHeight() / player.videoWidth();

                // strip the image number as well as the extension
                const thumbBaseURL = previewURL.slice(0, -6);

                const sprites = [];

                for (let i = 0; i < division - 1; i++) {
                    // append the base URL with numbers, starting from 01
                    const url = thumbBaseURL + (i + 1 + '').padStart(2, '0') + '.jpg';

                    // using (division-1) thumbnails to cover all the segments
                    //
                    //
                    // thumbs(index):        0       1       2       3           div-3   div-2
                    //                       v       v       v       v             v       v
                    // timeline:     +-------+-------+-------+-------+-- ... ------+-------+-------+
                    //               ^           ^       ^       ^             ^       ^           ^
                    // time spans:   |___________|_______|_______|______ ... __|_______|___________|
                    //                     0         1       2       3           div-3     div-2

                    let start, timeSpan;

                    switch (i) {
                        case 0:
                            start = 0;
                            timeSpan = interval * 1.5;
                            break;

                        case division - 2:
                            start = interval * (0.5 + i);

                            // add extra 0.1 due to the floating point computation...
                            timeSpan = interval * (1.5 + 0.1);
                            break;

                        default:
                            start = interval * (0.5 + i);
                            timeSpan = interval;
                    }

                    sprites.push({
                        url, width, height, start,
                        duration: timeSpan,
                        interval: timeSpan,
                    });
                }

                player.thumbnailSprite({ sprites });
            });
        }
    }

    async function prettifyContentPage() {
        await ready;

        // show full description
        $('.field-name-body a.show').click();

        // enlarge content area
        $('.node-full .col-sm-12:last-child').removeClass('col-sm-12').addClass('col-sm-9').parent().append($('.container .sidebar'));
        $('.container>.col-sm-9, .node-full').removeClass('col-sm-9').addClass('col-sm-12');
        // $('.extra-content-block').remove();

        // move "liked by" block to the bottom
        $('#block-views-likes-block').appendTo('.sidebar .region-sidebar');
    }

    async function setupAutoDownload() {
        await ready;

        // wait for Bootstrap's initialization
        await delay(200);

        function getDownloadTarget(template = filenameTemplate) {
            const url = $('#download-options li:first-child a')[0].href;
            const ext = url.match(/Source(\.[^&]+)/)[1];

            const urlMatches = unescape(url).match(/file=.+\/(\d+)_(\w+)_/);
            const uploadDate = urlMatches[1] * 1000;
            const id = urlMatches[2];

            const title = $('.node-info .title').text();
            const author = $('.node-info .username').text();

            const vars = {
                ID: id,
                TITLE: title,
                AUTHOR: author,
                DATE: formatDate(new Date()),
                DATE_TS: new Date(),
                UP_DATE: formatDate(new Date(uploadDate)),
                UP_DATE_TS: uploadDate,
            };

            // the keys should be sorted to prevent certain keys from overriding its longer form
            // e.g. "DATE_TS" gets populated with DATE instead of DATE_TS
            const sortedKeys = Object.keys(vars).sort((a, b) => b.length - a.length);

            const filename = sortedKeys.reduce((_filename, key) => _filename.replace(key, vars[key]), template)
                // strip characters disallowed in file path
                .replace(/[*/:<>?\\|]/g, '');

            return {
                url,
                filename: filename + ext,
            };
        }

        const downloadBtn = $('#download-button');
        const downloadBtnHTML = downloadBtn.html();

        // a function to abort the current download
        let abortDownload;

        unsafeWindow.onbeforeunload = () => {
            if (abortDownload) {
                // the message is unlikely to be displayed in modern browsers but, just in case
                return 'Download still in progress, would you like to abort it and exit?';
            }
        };

        unsafeWindow.onunload = () => abortDownload && abortDownload();

        if (GM_info.downloadMode !== 'disabled') {
            downloadBtn.off('click').click(function(e) {
                try {
                    e.preventDefault();

                    this.blur();

                    const likeBtn = $('.flag-like a');

                    // like button exists if user has logged in
                    if (likeBtn.length) {
                        // like the video if not liked
                        if (!likeBtn.attr('href').includes('unflag')) {
                            likeBtn.click();
                        }
                    }

                    const downloadTarget = getDownloadTarget();

                    downloadBtn.addClass('btn-disabled');

                    let onprogress;

                    // progress is only available in the "native" mode
                    if (GM_info.downloadMode !== 'browser') {
                        onprogress = (e) => {
                            const progress = ~~(e.loaded / e.total * 100);
                            downloadBtn.html(downloadBtnHTML + ' ' + progress + '%');
                        };

                        onprogress({ loaded: 0, total: 1 });
                    }

                    const { abort } = GM_download({
                        url: downloadTarget.url,
                        name: downloadTarget.filename,
                        saveAs: true,
                        onload: downloadEnded,
                        onerror: downloadEnded,
                        ontimeout: downloadEnded,
                        onprogress,
                    });

                    // aborting will be handled by the browser's download manager in non-native mode
                    if (GM_info.downloadMode === 'native') {
                        abortDownload = abort;
                    }
                } catch (e) {
                    showError(e + '');
                }
            });
        } else {
            showError(new Error('Download has been disabled, the default method will be used instead.'));
        }

        function downloadEnded(e) {
            if (e && e.error) {
                console.warn('Download error', e);
                showError(`Download error (${e.error}): ${e.details.current}`);
            }

            downloadBtn.removeClass('btn-disabled').html(downloadBtnHTML);

            abortDownload = undefined;
        }

        function showError(msg) {
            $('#download-options').before(`<div class="text-danger">${msg}</div>`);
        }

        $('<a id="options-switch" class="icon-btn glyphicon glyphicon-cog"></a>')
            .insertAfter('#download-button')
            .click(() => {
                $('#download-options').toggleClass('hidden');
                $('#filename-input').trigger('input');
            });

        $(`
        <div class="page-node-edit">
            <h3>Download filename</h3>
            <p>The filename template to use when downloading a video.</p>
            <p>Note the userscript settings will be lost when exiting the incognito mode,
              so in order to apply the settings permanently, you need to modify them in non-incognito mode.</p>
            <p>If you're using Tampermonkey, you can check the <a href="https://greasyfork.org/scripts/416003-iwara-enhancement">description</a>
              for how to improve the download experience.</p>
            <pre>ID          the video's ID
TITLE       title
AUTHOR      author's name
DATE        date time when the download starts
DATE_TS     the DATE in timestamp format
UP_DATE     date time when the video was uploaded
UP_DATE_TS  the UP_DATE in timestamp format</pre>
            <input type="text" id="filename-input" class="form-text" value="${filenameTemplate}">
            <a id="filename-submit" class="icon-btn glyphicon glyphicon-ok" title="Apply"></a>
            <a id="filename-reset" class="icon-btn glyphicon glyphicon-repeat" title="Reset to default"></a>
            <p id="filename-preview"></p>
        </div>`)
            .prependTo('#download-options .panel-body');

        $('#filename-input').on('input', function(e) {
            $('#filename-preview').text(getDownloadTarget(this.value).filename);

            const isChanged = this.value !== filenameTemplate;
            const isDefault = this.value === DEFAULT_FILENAME_TEMPLATE;
            $('#filename-submit')[isChanged ? 'show' : 'hide']();
            $('#filename-reset')[!isDefault ? 'show' : 'hide']();
        });

        $('#filename-submit').hide().click(() => {
            filenameTemplate = $('#filename-input').val();
            GM_setValue(KEY_FILENAME, filenameTemplate);
            $('#filename-input').trigger('input');
        });

        $('#filename-reset').hide().click(() => {
            $('#filename-input').val(DEFAULT_FILENAME_TEMPLATE);
            $('#filename-submit').click();
        });
    }

    async function enhanceSearch() {
        await ready;

        $('.node-image').each(function() {
            const thiz = $(this);
            const twitterShareLink = thiz.find('[title="Share on Twitter"]').attr('href');

            if (twitterShareLink) {
                let iwaraLink = twitterShareLink.slice(twitterShareLink.indexOf('http', 10));

                iwaraLink = decodeURIComponent(iwaraLink);

                thiz.find('h1').wrapInner(`<a href="${iwaraLink}"></a>`);
            }
        });
    }

    function repeat(fn, interval = 200) {
        if (fn()) {
            return 0;
        }

        const id = setInterval(() => {
            try {
                fn() && clearInterval(id);
            } catch (e) {
                clearInterval(id);
            }
        }, interval);

        return id;
    }

    // non-cancelable
    function repeatUntil(fn, interval) {
        return new Promise(resolve => repeat(() => {
            const result = fn();

            if (result) {
                resolve(result);
                return true;
            }
        }, interval));
    }

    function delay(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    function formatDate(date) {
        const pad = num => String(num).padStart(2, '0');
        return [
            date.getFullYear(), date.getMonth() + 1, date.getDate(),
            date.getHours(), date.getMinutes(), date.getSeconds(),
        ]
            .map(pad).join('');
    }
}

// language=CSS
const GLOBAL_STYLES = `
    /* ============================= large screen mode ============================= */

    @media (min-width: 2000px) {
        .container {
            width: 1984px;
        }

        .slick-slider {
            height: 920px !important;
        }

        .slick-list img {
            width: 1800px;
        }

        .comment .user-avatar {
            width: 8.33333333%;
        }
    }

    @media (min-width: 3000px) {
        .container {
            width: 2976px;
        }
    }

    /* ============================= dark mode ============================= */

    .dark {
        background-color: #222;
        color: #F8F8F8;
    }

    .dark li a.active {
        color: #02e8bb;
    }

    .dark body,
    .dark footer,
    .dark .panel,
    .dark section#content > .container,
    .dark .node.node-full.node-video .node-info,
    .dark .node.node-full.node-image .node-info,
    .dark tr.even,
    .dark tr.odd,
    .dark .table-striped > tbody > tr:nth-child(odd) > td,
    .dark .table-striped > tbody > tr:nth-child(odd) > th {
        background-color: inherit;
    }

    .dark .node.node-full .node-buttons,
    .dark .node.node-full .field-name-body a.show,
    .dark .node.node-full .field-name-field-categories .field-items .field-item,
    .dark .node.node-full .field-name-field-image-categories .field-items .field-item,
    .dark .panel-default > .panel-heading,
    .dark .panel-default > .panel-footer,
    .dark .table-striped > tbody > tr:nth-child(even) > td,
    .dark .table-striped > tbody > tr:nth-child(even) > th,
    .dark .views-field.views-field-last-updated.active,
    .dark .privatemsg-header-lastupdated.active,
    .dark .page-messages #privatemsg-list-form tr,
    .dark .page-messages .private-message .message.mine,
    .dark .view-profile.view-display-id-block,
    .dark .view-id-content table > tbody > tr:nth-child(odd) > td,
    .dark .view-id-content table > tbody > tr:nth-child(odd) > th,
    .dark table.sticky-header,
    .dark .well,
    .dark .jumbotron,
    .dark select option {
        background-color: #2a2a2a;
    }

    .dark .page-messages .private-message .message.theirs,
    .dark .view-profile.view-display-id-block .views-field-field-about {
        background-color: #444;
    }

    .dark .page-node-add .form-textarea,
    .dark .page-node-edit .form-textarea,
    .dark .page-node-add .form-text,
    .dark .page-node-edit .form-text,
    .dark pre,
    .dark select,
    .dark textarea,
    .dark input:not(.btn):not(.form-submit) {
        background-color: rgba(255, 255, 255, .05);
    }

    .dark .view-profile.view-display-id-block .views-field-field-about,
    .dark .panel-default,
    .dark .panel-default > .panel-heading,
    .dark .panel-default > .panel-footer,
    .dark .well,
    .dark pre,
    .dark textarea,
    .dark input[type="text"],
    .dark table,
    .dark thead,
    .dark tbody,
    .dark tfoot,
    .dark tr,
    .dark th,
    .dark td {
        border-color: #333 !important;
    }

    .dark h1,
    .dark h2,
    .dark h3,
    .dark h4,
    .dark h5,
    .dark h6 {
        border-color: #666 !important;
    }

    .dark body,
    .dark .node.node-teaser h3.title a,
    .dark .panel-default > .panel-heading,
    .dark .page-node-add .form-textarea,
    .dark .page-node-edit .form-textarea,
    .dark .page-node-add .form-text,
    .dark .page-node-edit .form-text {
        color: inherit;
    }

    /* ============================= item list ============================= */

    #block-mainblocks-sub-menu .list-inline {
        display: inline-block;
    }

    #block-mainblocks-sub-menu .checkbox {
        display: inline-block;
        margin: 0 16px;
        font-size: inherit;
    }

    /* hide likes icons only when displaying like rates */
    .hide-like-rates .like-rate,
    body:not(.hide-like-rates) .likes-icon.left-icon {
        margin: 0;
        width: 0;
        overflow: hidden;
    }

    .preview-placeholder {
        position: relative;
        display: block;
        padding-bottom: calc(100% * 150 / 220); /* numbers from the width and height attributes of preview images */
    }

    .preview-placeholder > * {
        position: absolute;
        top: 0;
        right: 0;
        bottom: 0;
        left: 0;
        display: flex;
        justify-content: center;
        align-items: center;
        color: #888;
        font-size: 1.5em;
        text-align: center;
        line-height: 1.2;
        background: rgba(128, 128, 128, .1);
    }

    .highlight {
        background-color: #79ecd6;
    }

    .highlight .username {
        color: #555;
    }

    .highlight .preview-placeholder > * {
        color: #EEE;
    }

    .dark .highlight {
        background-color: #048c72;
    }

    .dark .highlight .username {
        color: #CCC;
    }

    /* ============================= progress bar thumbnails ============================= */

    .vjs-mouse-display .vjs-time-tooltip {
        background-size: cover;
        text-shadow: 0 0 2px black, 0 0 2px black !important;
    }

    /* ============================= auto-download options ============================= */

    .btn-disabled {
        opacity: 0.7;
        pointer-events: none;
    }

    .icon-btn {
        margin-left: 4px;
        padding: 8px 8px;
        cursor: pointer;
    }

    #options-switch {
        vertical-align: middle;
    }

    #filename-input {
        margin-top: 2px;
        width: 400px;
        max-width: 100%;
    }

    #filename-preview {
        color: #777;
        font-size: 0.8em;
    }

    /* ============================= misc ============================= */
    video {
        outline: none !important;
    }
`;

main();