Last active
November 5, 2022 10:21
-
-
Save m-elewa/73dce6af6a958c89e9d371515b7d17bc to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @icon https://www.youtube.com/favicon.ico | |
// @name the translated version of "Youtube独轮车-Auto Youtube Chat Sender" | |
// @author necros & dislido | |
// @translatedBy melewa | |
// @description youtube wheelbarrow | |
// @match *://www.youtube.com/* | |
// @version 2.10 | |
// @grant none | |
// @namespace https://greasyfork.org/zh-CN/users/692472-necrosn | |
// ==/UserScript== | |
{ | |
if (window.top !== window.self) throw new Error('Non-top frame'); | |
const randomInt = (min, max) => Math.floor(Math.random() * (max - min + 1) + min); | |
const eventBus = { | |
ee: new EventTarget(), | |
on(type, fn, opt) { | |
this.ee.addEventListener(type, fn, opt); | |
}, | |
off(type, fn, opt) { | |
this.ee.removeEventListener(type, fn, opt); | |
}, | |
emit(type, detail) { | |
const event = new CustomEvent(type, { detail }); | |
this.ee.dispatchEvent(event); | |
}, | |
}; | |
const refs = { | |
sendBtn: null, | |
chatTxtInput: null, | |
runBtn: null, | |
remoteDanmakuConfig: [], | |
}; | |
const config = { | |
splitMode: 2, // Sentence segmentation | |
minCycleSec: 6, // Minimum sending interval (seconds) | |
maxCycleSec: 6, // Maximum sending interval (seconds) | |
randomDanmaku: false, // Send randomly | |
text: '', // Send message list | |
maxDanmakuLength: 200, // Maximum barrage word limit | |
minDanmakuLength: 20, // Minimum barrage length | |
startTime: '2020-10-24 00:07:00', | |
stopTime : '2020-10-24 00:07:00', | |
splitChar: ',;:。!?…,.!?,', // Sentence break | |
remoteDanmakuBase: '', | |
}; | |
eventBus.on('dlc.sendMsg', ({ detail: text }) => { | |
refs.chatTxtInput.textContent = text; | |
refs.chatTxtInput.dispatchEvent(new InputEvent('input')); | |
refs.sendBtn.click(); | |
}); | |
eventBus.on('setRef.remoteDanmakuConfig', ({ detail }) => { | |
refs.remoteDanmakuConfig = detail; | |
}); | |
try { | |
const savedCfg = JSON.parse(localStorage.getItem('duluncheCfg')); | |
Object.assign(config, savedCfg); | |
} catch (_) { | |
// noop | |
} | |
let timer;// Timer | |
// Mini renderer | |
const h = (tagname, attributes = {}, children = [], option = {}) => { | |
if (tagname instanceof Node) return tagname; | |
if (tagname instanceof Array) { | |
const frag = document.createDocumentFragment(); | |
tagname.forEach((it) => { | |
if (it instanceof Node) { | |
frag.appendChild(it); | |
} else if (Array.isArray(it)) { | |
frag.appendChild(h(it[0], it[1], it[2], it[3])); | |
} else if (['string', 'number'].includes(typeof it) || it) { | |
frag.appendChild(new Text(it)); | |
} | |
}); | |
return frag; | |
} | |
const el = document.createElement(tagname); | |
Object.entries(attributes).forEach(([key, value]) => { | |
if (key === 'style' && typeof value === 'object') { | |
Object.assign(el.style, value); | |
} else if (key.startsWith('$')) { | |
if (typeof value === 'function') { | |
el.addEventListener(key.slice(1), value); | |
} else { | |
el.addEventListener(key.slice(1), value.handleEvent, value); | |
} | |
} else el.setAttribute(key, value); | |
}); | |
if (['string', 'number'].includes(typeof children)) { | |
el.textContent = children; | |
} else if (children) { | |
el.appendChild(h(children)); | |
} | |
if (typeof option === 'function') { | |
option(el); | |
} else if (option.cb) { option.cb(el); } | |
return el; | |
}; | |
const formatTime = (time) => { | |
let h = Math.floor(time / 3600); | |
let m = Math.floor(time / 60 % 60); | |
let s = Math.floor(time % 60) | |
return h + ':' + m + ':' + s | |
} | |
// Various components | |
/** toast 'Prompt component',msg 'Can be a renderer' children 'Types of' */ | |
const Toast = (msg) => { | |
if (Toast.singletonDom) Toast.singletonDom.remove(); | |
const dom = h('div', { | |
class: 'dlc-toast', | |
$click: () => { | |
dom.remove(); | |
Toast.singletonDom = null; | |
}, | |
}, msg); | |
Toast.singletonDom = dom; | |
document.body.appendChild(dom); | |
}; | |
const ConfigField = ({ | |
label, type, name, props = {}, children = [], valueProp = 'value', helpDesc, | |
}, option) => ['div', {}, [ | |
helpDesc && ['span', { | |
class: 'help-icon', | |
$click: (ev) => { | |
ev.stopPropagation(); | |
Toast(helpDesc); | |
}, | |
}], | |
['label', {}, [ | |
label, | |
[type, { | |
...props, | |
$change: (ev) => { | |
eventBus.emit(`setConfig.${name}`, ev.target[valueProp]); | |
if (props.$change) props.$change(ev); | |
}, | |
}, children, (el) => { | |
el[valueProp] = config[name]; | |
eventBus.on(`setConfig.${name}`, ({ detail }) => { | |
config[name] = detail; | |
el[valueProp] = detail; | |
}); | |
}], | |
]], | |
], option]; | |
// End launch | |
eventBus.on('dlc.stop', () => { | |
refs.runBtn.innerText = 'Dispatch'; | |
clearTimeout(timer); | |
}); | |
const splitter = { | |
// Single sentence pattern | |
0: (text) => [text.substr(0, config.maxDanmakuLength)], | |
// Multi-sentence runner | |
2: (text) => text | |
.split('\n') | |
.map((it) => it.trim().substr(0, config.maxDanmakuLength)) | |
.filter(Boolean), | |
// Storytelling mode | |
1: (text) => { | |
const { maxDanmakuLength, minDanmakuLength, splitChar } = config; | |
const list = []; | |
text | |
.trim() | |
.replace(/\s+/g, ' ') | |
.split(new RegExp(`(?<=[${splitChar.replace(/(\\|])/g, '\\$1')}])`)) | |
.reduce((buf, curr, currIndex, arr) => { | |
buf += curr; | |
while (buf.length > maxDanmakuLength) { | |
list.push(buf.substr(0, maxDanmakuLength)); | |
buf = buf.substr(maxDanmakuLength); | |
} | |
if (currIndex === arr.length - 1) { | |
list.push(buf); | |
return ''; | |
} | |
if (buf.length < minDanmakuLength) return buf; | |
list.push(buf); | |
return ''; | |
}, ''); | |
return list; | |
}, | |
}; | |
// Launch barrage | |
eventBus.on('dlc.run', () => { | |
const { | |
maxCycleSec, minCycleSec, text, splitMode, randomDanmaku, | |
} = config; | |
// Check the settings | |
if (Number(config.minCycleSec) < 1) eventBus.emit('setConfig.minCycleSec', 1); | |
if (Number(config.maxCycleSec) < config.minCycleSec) eventBus.emit('setConfig.maxCycleSec', config.minCycleSec); | |
if (Number(config.minDanmakuLength) < 1) eventBus.emit('setConfig.minDanmakuLength', 1); | |
if (Number(config.maxDanmakuLength) < config.minDanmakuLength) { | |
eventBus.emit('setConfig.maxDanmakuLength', config.minDanmakuLength); | |
} | |
const localDanmakuList = splitter[splitMode](text); | |
const danmakuList = refs.remoteDanmakuConfig | |
.filter(Boolean) | |
.reduce((list, data) => list.concat(data.list), localDanmakuList); | |
if (!danmakuList.length) { | |
Toast('The barrage list is empty! '); | |
return; | |
} | |
localStorage.setItem('duluncheCfg', JSON.stringify(config)); | |
refs.runBtn.innerText = 'Cancel'; | |
const minCycleTime = parseInt(minCycleSec * 1000, 10); | |
const maxCycleTime = parseInt(maxCycleSec * 1000, 10); | |
const danmakuGener = (function* gen() { | |
if (+splitMode === 2 && randomDanmaku) { | |
while (true) yield danmakuList[randomInt(0, danmakuList.length - 1)]; | |
} else { | |
while (true) yield* danmakuList; | |
} | |
}()); | |
const nextTimer = () => { | |
timer = setTimeout( | |
() => { | |
eventBus.emit('dlc.sendMsg', danmakuGener.next().value); | |
nextTimer(); | |
}, | |
randomInt(minCycleTime, maxCycleTime), | |
); | |
}; | |
nextTimer(); | |
}); | |
// Console | |
const cmd = h('div', { class: 'dlc-cmd' }, [ | |
['div', { | |
class: 'dlc-titlebar', | |
$mousedown(ev) { | |
if (ev.target !== this) return; | |
const mask = h('div', { style: { | |
position: 'fixed', | |
left: '0', | |
top: '0', | |
width: '100vw', | |
height: '100vh', | |
}}); | |
this.style.cursor = 'all-scroll'; | |
document.body.appendChild(mask); | |
const { layerX, layerY } = ev; | |
const move = (ev) => { | |
cmd.style.left = `${ev.clientX - layerX}px`; | |
cmd.style.top = `${ev.clientY - layerY}px`; | |
}; | |
document.addEventListener('mousemove', move); | |
document.addEventListener('mouseup', (ev) => { | |
document.removeEventListener('mousemove', move); | |
this.style.cursor = ''; | |
mask.remove(); | |
}, { once: true }); | |
}, | |
}, [ | |
['button', { | |
class: 'dlc-btn', | |
$click: (ev) => { | |
ev.stopPropagation(); | |
if (refs.runBtn.innerText === 'Dispatch') eventBus.emit('dlc.run'); | |
else eventBus.emit('dlc.stop'); | |
}, | |
}, 'Dispatch', (el) => { refs.runBtn = el; }], | |
'v2.10', | |
['div', { | |
class: 'dlc-close-btn', | |
$click: (ev) => { | |
ev.stopPropagation(); | |
cmd.style.setProperty('display', 'none'); | |
}, | |
}, 'X'], | |
]], | |
['div', { style: { margin: '0 auto' } }, [ | |
ConfigField({ | |
label: '', | |
name: 'text', | |
type: 'textarea', | |
props: { | |
placeholder: 'Enter the content to be launched here', | |
style: { | |
width: '265px', | |
height: '155px', | |
overflow: 'scroll', | |
whiteSpace: 'pre', | |
}, | |
}, | |
}), | |
ConfigField({ | |
label: 'Minimum interval time(s):', | |
name: 'minCycleSec', | |
type: 'input', | |
props: { | |
type: 'number', | |
min: 3, | |
placeholder: 3, | |
style: { width: '48px', margin: '1px' }, | |
}, | |
}), | |
ConfigField({ | |
label: 'Maximum interval time(s):', | |
name: 'maxCycleSec', | |
type: 'input', | |
props: { | |
type: 'number', | |
min: 3, | |
placeholder: 3, | |
style: { width: '48px', margin: '1px' }, | |
}, | |
}), | |
ConfigField({ | |
label: 'Maximum length of barrage:', | |
name: 'maxDanmakuLength', | |
type: 'input', | |
props: { | |
type: 'number', | |
min: 1, | |
style: { width: '48px', margin: '1px' }, | |
}, | |
}), | |
ConfigField({ | |
label: 'Sentence segmentation:', | |
name: 'splitMode', | |
type: 'select', | |
children: [ | |
{ value: '2', text: 'Multi-sentence runner' }, | |
{ value: '0', text: 'Single sentence pattern' }, | |
{ value: '1', text: 'Storytelling mode' }, | |
].map(({ text, value }) => ['option', { value }, text]), | |
helpDesc: 'Multi-sentence wheel: one line per barrage; single sentence mode: continuous line; storytelling mode: break lines according to the form symbol and the lower limit of storytelling length', | |
}), | |
ConfigField({ | |
label: 'Random barrage:', | |
name: 'randomDanmaku', | |
type: 'input', | |
props: { type: 'checkbox' }, | |
valueProp: 'checked', | |
helpDesc: 'Random Barrage: Whether to randomly select a barrage from the barrage list to send, only used in the multi-sentence reel mode; enabling the storytelling mode will cause confusion', | |
}, (el) => { | |
if (+config.splitMode !== 2) el.classList.add('hide'); | |
eventBus.on('setConfig.splitMode', ({ detail: value }) => { | |
if (value !== '2') el.classList.add('hide'); | |
else el.classList.remove('hide'); | |
}); | |
}), | |
ConfigField({ | |
label: 'Minimum storytelling length:', | |
name: 'minDanmakuLength', | |
type: 'input', | |
props: { | |
type: 'number', | |
min: 1, | |
style: { width: '48px', margin: '1px' }, | |
}, | |
helpDesc: 'Lower limit of storytelling length: Only the storytelling mode takes effect, try to control the barrage length above this when segmenting sentences', | |
}, (el) => { | |
if (+config.splitMode !== 1) el.classList.add('hide'); | |
eventBus.on('setConfig.splitMode', ({ detail: value }) => { | |
if (value !== '1') el.classList.add('hide'); | |
else el.classList.remove('hide'); | |
}); | |
}), | |
ConfigField({ | |
label: 'Storyteller:', | |
name: 'splitChar', | |
type: 'input', | |
props: { | |
type: 'text', | |
min: 1, | |
style: { width: '48px', margin: '1px' }, | |
}, | |
helpDesc: 'Storytelling breaker: In storytelling mode, the article is divided into multiple bullet screens with the configured symbol, and then merged into the length of the bullet screen above the lower limit of the storytelling length. When it is empty, the sentence is fixed according to the lower limit of the storytelling length', | |
}, (el) => { | |
if (+config.splitMode !== 1) el.classList.add('hide'); | |
eventBus.on('setConfig.splitMode', ({ detail: value }) => { | |
if (value !== '1') el.classList.add('hide'); | |
else el.classList.remove('hide'); | |
}); | |
}), | |
ConfigField({ | |
label: h([ | |
[ | |
'button', { | |
class: 'dlc-btn', | |
$click: ({target}) => { | |
if (target.innerText !== 'Timing start') return; | |
const {startTime} = config; | |
const timeStamp = Date.parse(new Date(startTime)); | |
let timeRemain = timeStamp - new Date().getTime(); | |
target.innerText = formatTime(parseInt(timeRemain/1000)); | |
const startTimer = () => { | |
setTimeout( | |
() => { | |
if (timeRemain > 0){ | |
timeRemain -= 1000; | |
target.innerText = formatTime(parseInt(timeRemain/1000)); | |
startTimer(); | |
}else{ | |
eventBus.emit('dlc.run'); | |
target.innerText = 'Timing start' | |
} | |
}, | |
1000, | |
); | |
}; | |
startTimer(); | |
} | |
}, 'Timing start' | |
] | |
]), | |
name: 'startTime', | |
type: 'input', | |
props: { | |
type: 'text', | |
min: 1, | |
style: { width: '150px', margin: '1px' }, | |
}, | |
// helpDesc: 'Input time timing start,Format example:2020-10-21 17:31:00', | |
}), | |
ConfigField({ | |
label: h([ | |
[ | |
'button', { | |
class: 'dlc-btn', | |
$click: ({target}) => { | |
if (target.innerText !== 'Timing ends') return; | |
const {stopTime} = config; | |
const timeStamp = Date.parse(new Date(stopTime)); | |
let timeRemain = timeStamp - new Date().getTime(); | |
target.innerText = formatTime(parseInt(timeRemain/1000)); | |
const stopTimer = () => { | |
setTimeout( | |
() => { | |
if (timeRemain > 0){ | |
timeRemain -= 1000; | |
target.innerText = formatTime(parseInt(timeRemain/1000)); | |
stopTimer(); | |
}else{ | |
eventBus.emit('dlc.stop'); | |
target.innerText = 'Timing ends' | |
} | |
}, | |
1000, | |
); | |
}; | |
stopTimer(); | |
} | |
}, 'Timing ends' | |
] | |
]), | |
name: 'stopTime', | |
type: 'input', | |
props: { | |
type: 'text', | |
min: 1, | |
style: { width: '150px', margin: '1px' }, | |
}, | |
// helpDesc: 'Timing end of input time,Format example:2020-10-21 17:31:00', | |
}), | |
ConfigField({ | |
label: h([ | |
'Load remote barrage library:', | |
['button', { | |
class: 'dlc-btn', | |
$click: ({ target }) => { | |
if (target.innerText !== 'Update') return; | |
target.innerText = 'updating...'; | |
const { remoteDanmakuBase } = config; | |
const urlList = remoteDanmakuBase.split('\n').map((it) => it.trim()).filter(Boolean); | |
const queued = new Set(); | |
const allRemoteUrl = new Set(); | |
const loaded = []; | |
const loadFinish = () => { | |
eventBus.emit('setRef.remoteDanmakuConfig', loaded); | |
target.innerText = 'Update'; | |
Toast(h( | |
'pre', | |
{ style: { color: 'blue' } }, | |
refs.remoteDanmakuConfig | |
.map((data) => data.error || `${data.name || 'Anonymous Barrage Library'}: ${data.list.length}Article`) | |
.join('\n'), | |
)); | |
}; | |
const loadRemoteDanmaku = (url) => { | |
if (allRemoteUrl.has(url)) return; | |
queued.add(url); | |
allRemoteUrl.add(url); | |
fetch(url) | |
.then((data) => data.json()) | |
.then((data) => { | |
if (!data) { | |
loaded.push({ error: `[Get failed]${url}` }); | |
return; | |
} | |
if (Array.isArray(data.extends)) { | |
data.extends.forEach((extUrl) => loadRemoteDanmaku(extUrl)); | |
} | |
if (Array.isArray(data.list)) loaded.push(data); | |
}) | |
.catch((err) => { | |
console.error(err); | |
loaded.push({ error: `[Get failed]${url}` }); | |
}) | |
.finally(() => { | |
queued.delete(url); | |
if (queued.size === 0) loadFinish(); | |
}); | |
}; | |
urlList.forEach((url) => loadRemoteDanmaku(url)); | |
}, | |
}, 'Update'], | |
['span', {}, '(Barrage library not loaded)', (el) => { | |
eventBus.on('setRef.remoteDanmakuConfig', ({ detail }) => { | |
const totalLength = detail.reduce( | |
(total, data) => total + ((data && data.list.length) || 0), | |
0, | |
); | |
el.innerText = `(Loaded${totalLength}Article)`; | |
}); | |
}], | |
]), | |
name: 'remoteDanmakuBase', | |
type: 'textarea', | |
props: { | |
placeholder: 'Support multiple URLs, each line', | |
style: { | |
width: '265px', | |
height: '70px', | |
overflow: 'scroll', | |
whiteSpace: 'pre', | |
}, | |
}, | |
helpDesc: 'Load the barrage list from the specified address, support adding multiple addresses, one line each; the remote barrage list will be added to the local barrage list; the updated barrage will take effect the next time it is dispatched', | |
}), | |
]], | |
]); | |
// Default floating window | |
let tip = false; | |
const suspension = h('div', { | |
class: 'dlc-suspension', | |
$click: () => { | |
cmd.style.setProperty('display', 'block'); | |
if (!tip) { | |
tip = true; | |
Toast('In the multi-sentence runner mode, please separate each sentence with a carriage return. For your own account, it is recommended to adjust the speaking interval to more than 8000;'); | |
} | |
}, | |
}, 'Initializing...', (el) => { | |
eventBus.on('dlc.ready', () => { | |
el.textContent = 'Wheelbarrow console'; | |
}, { once: true }); | |
}); | |
const initChatCtx = async () => { | |
try { | |
const chatFrameCtx = document.getElementById('chatframe').contentWindow; | |
refs.sendBtn = chatFrameCtx.document.querySelector('#send-button button'); // Send button | |
refs.chatTxtInput = chatFrameCtx.document.querySelector('#input.yt-live-chat-text-input-field-renderer'); // Input box | |
if (!refs.sendBtn || !refs.chatTxtInput) throw new Error('Initializing retrying..'); | |
eventBus.emit('dlc.ready'); | |
} catch (_) { | |
setTimeout(initChatCtx, 1000); | |
} | |
}; | |
initChatCtx(); | |
// Window width changes will cause the chat bar to reload | |
setInterval(() => { | |
try { | |
const chatFrameCtx = document.getElementById('chatframe').contentWindow; | |
const sendBtn = chatFrameCtx.document.querySelector('#send-button button'); // Send button | |
const chatTxtInput = chatFrameCtx.document.querySelector('#input.yt-live-chat-text-input-field-renderer'); // Input box | |
if (sendBtn) refs.sendBtn = sendBtn; | |
if (chatTxtInput) refs.chatTxtInput = chatTxtInput; | |
} catch (_) { | |
} | |
}, 60000); | |
window.dulunche = { | |
config: new Proxy(config, { | |
set(_, p, value) { | |
eventBus.emit(`setConfig.${p}`, value); | |
}, | |
}), | |
eventBus, | |
}; | |
document.body.appendChild(suspension); | |
document.body.appendChild(cmd); | |
document.body.appendChild(h('style', {}, ` | |
.dlc-cmd { | |
background: #FFFFFF; | |
overflow-y: auto; | |
overflow-x: hidden; | |
z-index: 998; | |
position: fixed; | |
padding:5px; | |
width: 290px; | |
height: 370px; | |
box-sizing: content-box; | |
border: 1px solid #ff921a; | |
border-radius: 5px; | |
right: 10px; | |
top: 30%; | |
display: none; | |
} | |
.dlc-titlebar { | |
user-select: none; | |
background-color: #fb5; | |
} | |
.dlc-close-btn { | |
display: inline-block; | |
margin-top: 3px; | |
position: relative; | |
text-align: center; | |
width: 19px; | |
height: 19px; | |
color: white; | |
cursor: pointer; | |
float: right; | |
margin-right: 5px; | |
background-color: black; | |
border: gray 1px solid; | |
line-height: 21px; | |
} | |
.dlc-btn { | |
display: inline-block; | |
background: #f70; | |
color: #FFFFFF; | |
width: 70px; | |
height: 24px; | |
margin: 2px; | |
} | |
.dlc-suspension { | |
background: #1A59B7; | |
color:#ffffff; | |
overflow: hidden; | |
z-index: 997; | |
position: fixed; | |
padding:5px; | |
text-align:center; | |
width: 85px; | |
height: 22px; | |
border-radius: 5px; | |
right: 10px; | |
top: 30%; | |
} | |
.dlc-toast { | |
z-index: 10000; | |
position: fixed; | |
top: 10px; | |
right: 10px; | |
background: lightgrey; | |
max-width: 300px; | |
padding: 16px; | |
border: gray 1px solid; | |
} | |
.dlc-toast::after { | |
content: '(Click close)'; | |
} | |
.help-icon::after { | |
content: '?'; | |
margin-right: 4px; | |
display: inline-block; | |
background-color: #ddd; | |
border-radius: 50%; | |
width: 16px; | |
height: 16px; | |
line-height: 16px; | |
text-align: center; | |
border: #999 1px solid; | |
font-size: 12px; | |
} | |
.hide { | |
display: none; | |
} | |
`)); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment