您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
allows to play audio to not repeat same thing, works together with this server https://github.com/sergeynordicresults/videochat-extension/blob/main/server/server.js, plays https://github.com/sergeynordicresults/videochat-extension/blob/main/server/rofi-audio--ru.txt. Also one can use https://github.com/sergeynordicresults/videochat-extension/blob/main/server/cli.js in https://github.com/sergeynordicresults/.dotfiles/blob/663dc7e60fecb89f24d5ffcda73c1a12ea50eb5c/i3/config#L281
// ==UserScript== // @name Chatruletka - dont repeat, play audio using custom server // @namespace https://github.com/sergeynordicresults/videochat-extension // @version 2025-08-08 // @description allows to play audio to not repeat same thing, works together with this server https://github.com/sergeynordicresults/videochat-extension/blob/main/server/server.js, plays https://github.com/sergeynordicresults/videochat-extension/blob/main/server/rofi-audio--ru.txt. Also one can use https://github.com/sergeynordicresults/videochat-extension/blob/main/server/cli.js in https://github.com/sergeynordicresults/.dotfiles/blob/663dc7e60fecb89f24d5ffcda73c1a12ea50eb5c/i3/config#L281 // @author Serhii Khoma // @match https://chatruletka.com/ // @icon https://www.google.com/s2/favicons?sz=64&domain=chatruletka.com // @run-at document-idle // @grant none // ==/UserScript== ; (function () { 'use strict'; function waitForElement(selector, timeout = 5000) { return new Promise((resolve, reject) => { const el = document.querySelector(selector); if (el) return resolve(el); const observer = new MutationObserver(() => { const el = document.querySelector(selector); if (el) { observer.disconnect(); resolve(el); } }); observer.observe(document.body, { childList: true, subtree: true }); setTimeout(() => { observer.disconnect(); reject(new Error('Timeout waiting for element ' + selector)); }, timeout); }); } function arrayEquals(a, b) { if (!Array.isArray(a) || !Array.isArray(b)) return a === b; if (a.length !== b.length) return false; return a.every((val, i) => val === b[i]); } function createWatchedVariable(initialValue) { let value = initialValue; const listeners = new Set(); return { get() { return value; }, set(newVal) { if (newVal !== value) { value = newVal; listeners.forEach(fn => fn(newVal)); } }, subscribe(fn) { listeners.add(fn); // return unsubscribe function return () => listeners.delete(fn); } }; } function subscribeDistinct(watchedVar, isEqual = (a, b) => a === b, callback) { let lastVal = watchedVar.get(); return watchedVar.subscribe(newVal => { if (!isEqual(lastVal, newVal)) { lastVal = newVal; callback(newVal); } }); } async function createChatMessagesObserver(subscriberTextOnChanged) { const targetNode = await waitForElement('.chat__messages') if (!targetNode) { throw new Error('Could not find .chat__messages element.'); } let lastText = null; const observer = new MutationObserver(() => { const textContent = targetNode.textContent; console.log('new textContent', textContent) console.log('textContent !== lastText', textContent !== lastText) if (textContent !== lastText) { lastText = textContent; subscriberTextOnChanged(textContent); } }); observer.observe(targetNode, { childList: true, subtree: true, characterData: true, }); // Return a function to stop observing return () => observer.disconnect(); } // searching, found, stop function chatMessageToVariableState(textContent, allowedCountries) { console.log('chatMessageToVariableState', textContent, allowedCountries) if (textContent.includes('Searching')) { return ["searching"]; } else if (textContent.includes('Connection established')) { const isAllowedCountry = allowedCountries.some(country => textContent.includes(country)); if (!isAllowedCountry) { return ["found", null] } const foundCountry = allowedCountries.find(c => textContent.includes(c)); return ["found", foundCountry]; } return ["stop"]; // default state instead of throwing error } async function loadScript(src) { return new Promise((resolve, reject) => { if (document.querySelector(`script[src="${src}"]`)) return resolve(); const s = document.createElement('script'); s.src = src; s.onload = resolve; s.onerror = reject; document.head.appendChild(s); }); } function playAudio(audio) { audio.pause(); audio.currentTime = 0; audio.play().catch(e => console.warn("Audio play failed:", e)); } async function get_fetchTextResponse(url) { try { const res = await fetch(url); const text = await res.text(); return text; } catch (err) { console.error('Fetch error:', err); } } function createGlobalState() { // Global state management - now stores full array from chatMessageToVariableState let observerStopFunction = null; const state = createWatchedVariable(["stop"]); // stores array: ["searching"] | ["found", "found allowed country e.g. Russia" | null] | ["stop"] let allowedCountries = []; let onStateChangedDistinctFn = null; let onStateChangedFn = null; return { getState() { return state.get(); }, setAllowedCountries(newAllowedCountries) { allowedCountries = newAllowedCountries; }, setOnStateChanged(fn) { if (onStateChangedFn) { throw new Error('onStateChangedFn already full, cannot set another'); } onStateChangedFn = fn; const unsubscribe = state.subscribe(fn) return () => { unsubscribe() onStateChangedFn = null }; }, setOnStateChangedDistinct(fn) { if (onStateChangedDistinctFn) { throw new Error('onStateChangedDistinctFn already full, cannot set another'); } onStateChangedDistinctFn = fn; const unsubscribeDistinct = subscribeDistinct(state, arrayEquals, fn) return () => { unsubscribeDistinct() onStateChangedDistinctFn = null }; }, startObserver: async () => { if (observerStopFunction) { throw new Error('first should stop'); } observerStopFunction = await createChatMessagesObserver((textContent) => { console.log('Chat message changed:', textContent); const newStateArray = chatMessageToVariableState(textContent, allowedCountries); state.set(newStateArray); }); console.log('Chat messages observer started'); } }; } function countriesTextToArray(str) { return str.split(',').map(c => c.trim()).filter(Boolean); } function assertIsStringAndReturn(str) { if (typeof str === 'string') { return str } throw new Error('not string') } (async () => { // Load React, ReactDOM await Promise.all([ loadScript('https://unpkg.com/react@18/umd/react.production.min.js'), loadScript('https://unpkg.com/react-dom@18/umd/react-dom.production.min.js'), ]); const { createElement, useState, useEffect, useRef } = React; const getPort = () => localStorage.getItem('musicPlayerPort') || '3300'; const musicPlayerServerHost = (port) => `http://localhost:${port}`; const audioBaseURL = `http://localhost:${getPort()}/resources/audio`; const audioFound = new Audio(`${audioBaseURL}/found.mp3`); audioFound.preload = 'auto'; const audioSkip = new Audio(`${audioBaseURL}/skip.mp3`); audioSkip.preload = 'auto'; // Create global state instance const globalState = createGlobalState(); function UtteranceList({ utterances, onPlay }) { return ( React.createElement( 'ul', { id: 'utteranceList', style: { // margin-top: 100px; marginTop: '30px', paddingLeft: '20px', width: '100%' // position: 'fixed', // bottom: 0, // left: 0, // width: '100vw', // maxHeight: '150px', // overflowY: 'auto', // margin: 0, // padding: '8px 20px', // backgroundColor: '#fafafa', // borderTop: '1px solid #ccc', // boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', // fontSize: 'small', // zIndex: 9999, // boxSizing: 'border-box', } }, utterances.map((line, index) => React.createElement('li', { key: index, style: { cursor: 'pointer', margin: '4px 0', fontSize: 'x-small' }, onClick: () => onPlay(index) }, `${index + 1}. ${line}`) ) ) ); } function ControlPanel() { const [allowedCountriesText, setAllowedCountriesText] = useState('Russia, Ukraine'); const [port, setPort] = useState(getPort()); const [currentState, setCurrentState] = useState(assertIsStringAndReturn(globalState.getState()[0])); const [utterances, setUtterances] = useState([]); const sessionId = React.useMemo(() => Math.random().toString(36).slice(2), []); const lastFoundCountryRef = useRef('ru'); const [sendAutoplayOnNewFoundUser, setSendAutoplayOnNewFoundUser] = useState(true); const [isStateChangeSoundEnabled, setIsStateChangeSoundEnabled] = useState(true); const [notifySend, setNotifySend] = useState(null); // Reference to #roulette container and created div for portal const [portalContainer, setPortalContainer] = React.useState(null); React.useEffect(async () => { const roulette = await waitForElement('#roulette > .roulette-box'); if (!roulette) { throw new Error('!roulette') }; const container = document.createElement('div'); container.id = 'my-utterences-list' roulette.appendChild(container); setPortalContainer(container); // cleanup return () => { if (container.parentNode) { container.parentNode.removeChild(container); } }; }, []); const refreshUtterances = async () => { const res = await fetch(`${musicPlayerServerHost(port)}/refresh_list`); const array = await res.json(); if (!Array.isArray(array)) { throw new Error(`not array ${array}`) } setUtterances(array) }; const refreshNotifySend = async () => { const res = await fetch(`${musicPlayerServerHost(port)}/notify_send`); const b = await res.json(); if (typeof b !== 'boolean') { throw new Error(`not boolean ${b}`); } setNotifySend(b); }; useEffect(() => { console.log('setAllowedCountries') globalState.setAllowedCountries(countriesTextToArray(allowedCountriesText)); refreshUtterances() refreshNotifySend() // start watching chat messages try { globalState.startObserver(); } catch (err) { console.error(err); } }, []); useEffect(() => { function handleUnload() { const url = `http://localhost:${port}/autoplay_stop?sessionId=${sessionId}`; if (navigator.sendBeacon) { navigator.sendBeacon(url); } else { const xhr = new XMLHttpRequest(); xhr.open('GET', url, false); // synchronous fallback try { xhr.send(); } catch { // ignore } } } window.addEventListener('unload', handleUnload); return () => { window.removeEventListener('unload', handleUnload); }; }, [port, sessionId]); // re-attach if port or sessionId changes useEffect(() => { // Set up state change handler for distinct changes const unsubscribe = globalState.setOnStateChanged(newStateArray => { console.log('State changed undistinctly to:', newStateArray); const [stateType, foundAllowedCountry] = newStateArray; lastFoundCountryRef.current = foundAllowedCountry if (stateType === "found") { if (foundAllowedCountry === null) { // Country not in allowed list - skip console.log('Playing skip audio and clicking next'); if (isStateChangeSoundEnabled) { playAudio(audioSkip); } const nextBtn = Array.from(document.querySelectorAll('.btn.btn-main')).find( btn => btn.textContent.trim().toLowerCase() === 'next' ); if (nextBtn) { nextBtn.click(); } } } }) const unsubscribeDistinct = globalState.setOnStateChangedDistinct(newStateArray => { console.log('State changed distinctly to:', newStateArray, 'sendAutoplayOnNewFoundUser', sendAutoplayOnNewFoundUser); const [stateType, foundAllowedCountry] = newStateArray; setCurrentState(stateType); if (stateType === "searching") { console.log('User disconnected — stopping autoplay'); get_fetchTextResponse(`${musicPlayerServerHost(port)}/autoplay_stop?sessionId=${sessionId}`); } else if (stateType === "found" && foundAllowedCountry !== null && sendAutoplayOnNewFoundUser) { console.log(`Starting autoplay for ${foundAllowedCountry}`); if (isStateChangeSoundEnabled) { playAudio(audioFound); } get_fetchTextResponse( `${musicPlayerServerHost(port)}/autoplay_start?waitMilliseconds=2000&country=${foundAllowedCountry.toLowerCase()}&sessionId=${sessionId}` ); } }); return () => { unsubscribe(); unsubscribeDistinct(); } }, [port, sessionId, allowedCountriesText, isStateChangeSoundEnabled]); // Save port if valid (simple numeric check) const onPortChange = (e) => { const val = e.target.value.trim(); if (/^\d{1,5}$/.test(val)) { localStorage.setItem('musicPlayerPort', val); setPort(val); } else { setPort(val); // keep input but don't save invalid port } }; const autoplayStart = () => { const lastFoundCountry = lastFoundCountryRef.current; if (!lastFoundCountry) { throw new Error(`no lastFoundCountry ${lastFoundCountry}`) }; get_fetchTextResponse( `${musicPlayerServerHost(port)}/autoplay_start?waitMilliseconds=2000&country=${lastFoundCountry.toLowerCase()}&sessionId=${sessionId}` ); }; const autoplayStop = () => { get_fetchTextResponse(`${musicPlayerServerHost(port)}/autoplay_stop?sessionId=${sessionId}`); }; const playUtterance = async (index) => { get_fetchTextResponse(`${musicPlayerServerHost(port)}/choose/${encodeURIComponent(index + 1)}`); }; const rofi = async () => { get_fetchTextResponse(`${musicPlayerServerHost(port)}/rofi`); }; return createElement( React.Fragment, null, createElement( 'div', { style: { marginTop: '10px', padding: '8px', background: 'rgb(193 214 230)', display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap', // maxWidth: '400px', flexDirection: 'column', } }, // Status indicator createElement('div', { style: { display: 'flex', gap: '8px', width: '100%' } }, createElement('div', { style: { padding: '4px 8px', borderRadius: '4px', fontSize: '12px', fontWeight: 'bold', background: currentState === 'found' ? '#2ecc71' : currentState === 'searching' ? '#f39c12' : currentState === 'skip' ? '#e74c3c' : '#95a5a6', color: 'white' } }, `Status: ${currentState.toUpperCase()}`), createElement('span', { onClick: () => setSendAutoplayOnNewFoundUser(prev => !prev), style: { padding: '4px 8px', borderRadius: '4px', fontSize: '12px', fontWeight: 'bold', background: sendAutoplayOnNewFoundUser ? '#27ae60' : '#c0392b', color: 'white', cursor: 'pointer', } }, sendAutoplayOnNewFoundUser ? 'Sending autoplay on new found user' : 'Not sending autoplay on new user'), createElement('span', { onClick: () => setIsStateChangeSoundEnabled(prev => !prev), style: { padding: '4px 8px', borderRadius: '4px', fontSize: '12px', fontWeight: 'bold', background: isStateChangeSoundEnabled ? '#27ae60' : '#c0392b', color: 'white', cursor: 'pointer', } }, isStateChangeSoundEnabled ? 'State change sound enabled' : 'State change sound disabled'), createElement('span', { onClick: () => setNotifySend(prev => { if (prev === null) { return null } const n = !prev if (n !== prev) { fetch(`${musicPlayerServerHost(port)}/notify_send`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ value: n }) }) } return n }), style: { padding: '4px 8px', borderRadius: '4px', fontSize: '12px', fontWeight: 'bold', background: notifySend === null ? 'gray' : notifySend ? '#27ae60' : '#c0392b', color: 'white', cursor: 'pointer', } }, notifySend === null ? 'Loading...' : notifySend ? 'Notify send enabled' : 'Notify send disabled'), ), // inputs createElement('div', { style: { display: 'flex', gap: '8px', width: '100%' } }, createElement('input', { type: 'text', value: allowedCountriesText, onChange: e => setAllowedCountriesText(e.target.value), placeholder: 'Allow only countries', style: { width: '100%', padding: '4px' }, title: 'Comma-separated list of countries to allow', }), createElement('input', { type: 'text', value: port, onChange: onPortChange, placeholder: 'Server port', style: { width: '70px', padding: '4px' }, title: 'Port for the music player server (numeric only)', }), ), // buttons createElement('div', { style: { display: 'flex', gap: '4px', width: '100%', fontSize: '19px' } }, createElement( 'button', { onClick: autoplayStart, style: { padding: '2px 6px', background: '#2ecc71', color: '#fff', border: 'none', cursor: 'pointer', userSelect: 'none', } }, 'Autoplay Start' ), createElement( 'button', { onClick: autoplayStop, style: { padding: '2px 6px', background: 'red', color: '#fff', border: 'none', cursor: 'pointer', userSelect: 'none', } }, 'Autoplay Stop' ), createElement( 'button', { onClick: rofi, style: { padding: '2px 6px', background: 'blue', color: '#fff', border: 'none', cursor: 'pointer', userSelect: 'none', } }, 'Rofi' ), ), ), portalContainer ? ReactDOM.createPortal( createElement(UtteranceList, { utterances, onPlay: playUtterance }), portalContainer ) : null ); } const buttonsWrapper = await waitForElement('.chat-container > .buttons > .buttons__wrapper', 30000) const container = document.createElement('div'); buttonsWrapper.appendChild(container); ReactDOM.createRoot(container).render(React.createElement(ControlPanel)); // Remove navbar if present const hpNavbar = document.getElementById('hpNavbar'); if (hpNavbar && hpNavbar.parentElement) { hpNavbar.parentElement.remove(); } // Apply custom styles const style = document.createElement('style'); style.textContent = ` #roulette.vertical .roulette-box .chat-container .buttons .buttons__wrapper { display: flex; flex-direction: column; } #roulette.vertical .roulette-box .chat-container .buttons .buttons__wrapper .buttons__button { min-height: 20px; } #utteranceList li:focus { border: 2px solid #3498db; padding: 2px 6px; border-radius: 4px; background: #eef6ff; } `; document.head.appendChild(style); })(); })();