Iwara Enhancement

Multiple UI enhancements for better experience.

À partir de 2021-09-01. Voir la dernière version.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==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/[email protected]/dist/video.min.js#sha256=9HM07Of11yw3TL/m0BxP9pw08qXmG/xOTDc1d3sp2Wo=
// @resource     vjs-css https://cdn.jsdelivr.net/npm/[email protected]/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/[email protected]/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();