Goplay Viki Comments (Waterfall)

Enjoy your Asian dramas with Viki comments displayed as waterfall.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Goplay Viki Comments (Waterfall)
// @namespace    http://tampermonkey.net/
// @version      0.1.1
// @description  Enjoy your Asian dramas with Viki comments displayed as waterfall.
// @author       Niss
// @match        https://goplay.ml/*
// @match        https://goplay.su/*
// @require      https://code.jquery.com/jquery-3.6.0.min.js
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @license MIT
// ==/UserScript==

/*
MIT License

Copyright (c) 2025 Niss

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
*/

(function() {
    'use strict';

    let state = {
        currentUrl: window.location.href,
        commentsData: [],
        shownCommentIds: new Set(),
        retryCount: 0,
        fetchInProgress: false,
        mouseTimer: null,
        resizerTimer: null,
        isResizing: false,
        settings: GM_getValue('viki_settings_v9_3', {
            width: 18,
            smartSpoilerSuppression: true,
            isActive: true,
            spoilerKeywords: "spoiler,died,killed",
            bufferBefore: 20,
            bufferAfter: 10
        })
    };

    let checkCommentsInterval;

    const injectStyles = () => {
        $('#viki-ultra-style').remove();
        const width = state.settings.width;

        const css = `
            .jwplayer.jw-viki-active .jw-wrapper {
                width: ${100 - width}% !important;
                transition: ${state.isResizing ? 'none' : 'width 0.2s ease-in-out'};
            }
            #comments-waterfall {
                width: ${width}% !important;
                height: 100% !important;
                position: absolute !important;
                right: 0 !important;
                top: 0 !important;
                background-color: #000000 !important;
                color: #eee !important;
                overflow: hidden !important;
                z-index: 2147483647 !important;
                display: ${state.settings.isActive ? 'flex' : 'none'};
                flex-direction: column;
                border: none !important;
                font-family: sans-serif;
            }

            /* --- RESIZER AREA --- */
            #viki-resizer {
                position: absolute;
                left: 0;
                top: 0;
                width: 18px; /* Slightly wider for better accessibility */
                height: 100%;
                cursor: col-resize;
                z-index: 2147483648;
                display: flex;
                align-items: center;
                justify-content: center;
                transition: background 0.3s ease;
                background: transparent;
            }

            /* Gray background feedback when moving mouse */
            #viki-resizer.show-visuals {
                background: rgba(255, 255, 255, 0.15); /* Translucent gray background */
            }

            /* The "Two Lines" indicator */
            #viki-resizer::after {
                content: "";
                width: 4px;
                height: 40px;
                border-left: 1px solid rgba(255,255,255,0.6);
                border-right: 1px solid rgba(255,255,255,0.6);
                opacity: 0;
                transition: opacity 0.3s ease;
            }

            /* Show lines when moving mouse */
            #viki-resizer.show-visuals::after {
                opacity: 1;
            }

            #viki-status-overlay {
                position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
                text-align: center; width: 80%; font-size: 11px; color: #888; z-index: 10;
            }
            .loading-bar-bg { width: 100%; height: 2px; background: #222; margin-top: 8px; border-radius: 2px; overflow: hidden; }
            .loading-bar-fill { width: 0%; height: 100%; background: #3498db; transition: width 0.3s; }

            #viki-tools {
                position: absolute; top: 10px; right: 10px; z-index: 2147483647;
                display: flex; gap: 10px; opacity: 0; transition: opacity 0.2s;
            }
            #comments-waterfall:hover #viki-tools { opacity: 1; }

            #comments-container {
                flex-grow: 1; overflow-y: auto; padding: 20px 10px;
                display: flex; flex-direction: column-reverse; scrollbar-width: none;
            }
            #comments-container::-webkit-scrollbar { display: none; }

            .comment-item {
                background: rgba(255, 255, 255, 0.1); margin-bottom: 8px;
                padding: 10px; border-radius: 4px; font-size: 13px;
            }

            #viki-panel {
                position: absolute; top: 40px; right: 10px; background: #111;
                border: 1px solid #333; padding: 12px; border-radius: 6px;
                display: none; z-index: 2147483649; width: 180px; box-shadow: 0 5px 20px rgba(0,0,0,1);
            }
            .s-row { margin-bottom: 8px; font-size: 11px; }
            .s-label { display: block; margin-bottom: 2px; color: #666; font-size: 9px; }
            .viki-input { width: 100%; background: #1a1a1a; border: 1px solid #333; color: #fff; padding: 4px; border-radius: 3px; font-size: 10px; }
            #v-save-reload { width:100%; padding:6px; cursor:pointer; background:#222; color:#eee; border:1px solid #444; border-radius:3px; font-size: 10px; margin-top: 5px; }

            #viki-restore-btn {
                position: absolute; top: 15px; right: 60px; z-index: 2147483647;
                background: rgba(0,0,0,0.9); color: #eee; padding: 8px 16px;
                border-radius: 20px; cursor: pointer; border: 1px solid #333;
                font-size: 11px; transition: opacity 0.3s; opacity: 0; pointer-events: none;
            }
            #viki-restore-btn.visible { opacity: 1; pointer-events: auto; }
        `;
        $('<style id="viki-ultra-style">').text(css).appendTo('head');

        const player = $('.jwplayer');
        state.settings.isActive ? player.addClass('jw-viki-active') : player.removeClass('jw-viki-active');
    };

    function updateStatus(text, progress) {
        const overlay = $('#viki-status-overlay');
        overlay.find('.status-text').text(text.toUpperCase());
        overlay.find('.loading-bar-fill').css('width', progress + '%');
        if (progress >= 100) {
            setTimeout(() => overlay.fadeOut(500), 1000);
        } else {
            overlay.show();
        }
    }

    function applySmartFilter(rawComments) {
        updateStatus("Shielding Spoilers...", 70);
        if (!state.settings.smartSpoilerSuppression) return rawComments;
        const words = state.settings.spoilerKeywords.split(',').map(k => k.trim()).filter(k => k).join('|');
        if (!words) return rawComments;
        const regex = new RegExp(words, 'i');
        const dangerZones = [];
        rawComments.forEach(c => {
            if (regex.test(c.value)) {
                dangerZones.push({
                    start: c.time - (state.settings.bufferBefore * 1000),
                    end: c.time + (state.settings.bufferAfter * 1000)
                });
            }
        });
        const filtered = rawComments.filter(c => {
            if (regex.test(c.value)) return false;
            return !dangerZones.some(zone => (c.time >= zone.start && c.time <= zone.end));
        });
        updateStatus("Ready", 100);
        return filtered;
    }

    function renderUI() {
        $('#comments-waterfall, #viki-restore-btn').remove();

        const waterfall = $('<div id="comments-waterfall"></div>');
        const resizer = $('<div id="viki-resizer"></div>');
        const statusOverlay = $(`
            <div id="viki-status-overlay">
                <div class="status-text">INITIALIZING...</div>
                <div class="loading-bar-bg"><div class="loading-bar-fill"></div></div>
            </div>
        `);

        const tools = $(`
            <div id="viki-tools">
                <span style="cursor:pointer;" id="v-set">⚙️</span>
                <span style="cursor:pointer;" id="v-cls">❌</span>
            </div>
        `);

        const panel = $(`
            <div id="viki-panel">
                <div class="s-row" style="display:flex; justify-content:space-between; align-items:center;">
                    <span style="font-size:9px; color:#888;">PRE-FILTER SHIELD</span>
                    <input type="checkbox" id="v-spoiler" ${state.settings.smartSpoilerSuppression ? 'checked' : ''}>
                </div>
                <div class="s-row">
                    <span class="s-label">keywords (comma seperate)</span>
                    <textarea id="v-keywords" class="viki-input" style="height:40px; resize:none;">${state.settings.spoilerKeywords}</textarea>
                </div>
                <div class="s-row"><span class="s-label">Hide x comments before spoilers</span><input type="number" id="v-pre" class="viki-input" value="${state.settings.bufferBefore}"></div>
                <div class="s-row"><span class="s-label">Hide x comments after spoilers</span><input type="number" id="v-post" class="viki-input" value="${state.settings.bufferAfter}"></div>
                <button id="v-save-reload">SAVE & RELOAD</button>
            </div>
        `);

        const container = $('<div id="comments-container"></div>');
        const restoreBtn = $('<div id="viki-restore-btn">💬 Show Comments</div>');

        waterfall.append(resizer, statusOverlay, tools, panel, container);
        $('.jwplayer').append(waterfall, restoreBtn);

        // Show visuals (Gray Background + Lines) only when moving mouse anywhere on waterfall
        waterfall.on('mousemove', function() {
            if (state.settings.isActive) {
                resizer.addClass('show-visuals');
                clearTimeout(state.resizerTimer);
                state.resizerTimer = setTimeout(() => {
                    if (!state.isResizing) resizer.removeClass('show-visuals');
                }, 1500);
            }
        });

        $('#v-set').on('click', () => $('#viki-panel').toggle());
        $('#v-cls').on('click', () => { state.settings.isActive = false; save(); });
        restoreBtn.on('click', () => { state.settings.isActive = true; save(); });

        resizer.on('mousedown', (e) => {
            state.isResizing = true;
            resizer.addClass('show-visuals');
            $('body').css('cursor', 'col-resize');
            e.preventDefault();
        });

        $(document).on('mousemove', (e) => {
            if (!state.isResizing) return;
            const player = $('.jwplayer');
            const playerRect = player[0].getBoundingClientRect();
            const newWidthPx = playerRect.right - e.clientX;
            const newWidthPct = (newWidthPx / playerRect.width) * 100;
            if (newWidthPct > 5 && newWidthPct < 50) {
                state.settings.width = newWidthPct;
                injectStyles();
            }
        });

        $(document).on('mouseup', () => {
            if (state.isResizing) {
                state.isResizing = false;
                $('body').css('cursor', 'default');
                GM_setValue('viki_settings_v9_3', state.settings);
                window.dispatchEvent(new Event('resize'));
            }
        });

        $('.jwplayer').on('mousemove', function() {
            if (!state.settings.isActive) {
                restoreBtn.addClass('visible');
                clearTimeout(state.mouseTimer);
                state.mouseTimer = setTimeout(() => restoreBtn.removeClass('visible'), 2500);
            }
        });

        $('#v-save-reload').on('click', () => {
            state.settings.spoilerKeywords = $('#v-keywords').val();
            state.settings.bufferBefore = parseInt($('#v-pre').val());
            state.settings.bufferAfter = parseInt($('#v-post').val());
            state.settings.smartSpoilerSuppression = $('#v-spoiler').is(':checked');
            GM_setValue('viki_settings_v9_3', state.settings);
            location.reload();
        });
    }

    function save() {
        GM_setValue('viki_settings_v9_3', state.settings);
        injectStyles();
        if (state.settings.isActive) {
            $('#comments-waterfall').show();
            $('#viki-restore-btn').removeClass('visible');
        } else {
            $('#comments-waterfall').hide();
        }
        window.dispatchEvent(new Event('resize'));
    }

    function process() {
        if (!state.settings.isActive || typeof jwplayer !== 'function' || jwplayer().getState() !== 'playing') return;
        const curTime = Math.floor(jwplayer().getPosition() * 1000);
        const container = $('#comments-container');
        state.commentsData.forEach(c => {
            if (Math.abs(c.time - curTime) < 500 && !state.shownCommentIds.has(c.id)) {
                container.prepend(`
                    <div class="comment-item">
                        <div style="font-weight:bold; font-size:12px; margin-bottom:4px; color:#fff;">${c.user.name}</div>
                        <div style="color:#ddd;">${c.value}</div>
                    </div>
                `);
                state.shownCommentIds.add(c.id);
            }
        });
        if (container.children().length > 50) container.children().last().remove();
    }

    function fetch(id) {
        if (state.fetchInProgress) return;
        state.fetchInProgress = true;
        updateStatus("Fetching Viki Comments...", 30);
        GM_xmlhttpRequest({
            method: 'GET',
            url: `https://api.viki.io/v4/videos/${id}/timed_comments/all.json?stream_id=&app=100000a`,
            onload: (res) => {
                state.fetchInProgress = false;
                try {
                    updateStatus("Parsing Data...", 50);
                    state.commentsData = applySmartFilter(JSON.parse(res.responseText));
                    if (checkCommentsInterval) clearInterval(checkCommentsInterval);
                    checkCommentsInterval = setInterval(process, 500);
                } catch(e) { retry(id); }
            },
            onerror: () => retry(id)
        });
    }

    function retry(id) {
        state.fetchInProgress = false;
        if (state.retryCount < 5) {
            state.retryCount++;
            updateStatus(`Retry Connection (${state.retryCount})...`, 10);
            setTimeout(() => fetch(id), 3000);
        } else {
            updateStatus("Failed to load comments", 0);
        }
    }

    function init() {
        const sel = $("img.selectedepisode");
        if (sel.length === 0) return;
        const match = sel.attr('src').match(/\/v\/([^\/]+)/);
        if (match) {
            state.shownCommentIds.clear();
            injectStyles();
            renderUI();
            fetch(match[1]);
        }
    }

    $(window).on('load', () => setTimeout(init, 2000));
    setInterval(() => {
        if (state.currentUrl !== window.location.href) {
            state.currentUrl = window.location.href;
            init();
        }
    }, 3000);
})();