It reads comment text on streaming sites by speech synthesis.
// ==UserScript== // @name * Streaming Comment Reader chan // @name:ja * 配信コメント読み上げちゃん // @name:zh-CN * 朗读直播评论酱 // @namespace knoa.jp // @description It reads comment text on streaming sites by speech synthesis. // @description:ja ライブ配信サイトの新着コメントを音声で読み上げます。 // @description:zh-CN 用声音朗读直播网站的新到来评论。 // @include https://abema.tv/* // @include https://live.bilibili.com/* // @include https://www.douyu.com/* // @include https://live.fc2.com/* // @include https://www.huajiao.com/l/* // @include https://www.huya.com/* // @include http*://www.inke.cn/live* // @include https://live.line.me/channels/*/broadcast/* // @include https://live*.nicovideo.jp/watch/* // @include https://www.openrec.tv/live/* // @include https://www.pscp.tv/w/* // @include https://www.showroom-live.com/* // @include https://twitcasting.tv/* // @include https://www.twitch.tv/* // @include https://whowatch.tv/viewer/* // @include https://www.yizhibo.com/l/* // @include https://www.youtube.com/live_chat* // @include https://www.yy.com/* // @version 1.0.20 // @grant none // ==/UserScript== (function(){ const SCRIPTID = 'StreamingCommentReader-chan'; const SCRIPTNAME = 'Streaming Comment Reader chan'; const DEBUG = false;/* [update] Fix for Bilibili. Thank you for reporting!! [to do] ##語でも将棋が表示されてる? 動作しなければ報告歓迎、のパネル [possible] * Streaming Comment Reader に改名なのでは(少なくとも英語名) 「ビリビリ弾幕翻訳機」もあくまで#称だよな video.volumeと連動さるオプションありか [research] ニコニコ動画!!需要があるらしい https://greasyfork.org/ja/forum/discussion/63249/x しかし新着要素じゃないから一筋縄じゃダメだな ふわっち (デフォであるw) */ if(window === top && console.time) console.time(SCRIPTID); if(!('speechSynthesis' in window)) return console.log(SCRIPTID, 'speechSynthesis undefined.'); const USERLANGUAGE = top.navigator.language; const SITELANGUAGE = (top.document.documentElement && top.document.documentElement.lang) ? top.document.documentElement.lang : USERLANGUAGE; const _TEXTS = { en: { scriptname: () => `${SCRIPTNAME}`, configs: () => `${SCRIPTNAME} configs`, test: () => 'Trial', text: () => 'this is a test ABC', speech: () => 'Speech', volume: () => 'volume', pitch: () => 'pitch', voice: () => 'voice', fast: () => 'When comments flow fast', fastest: () => 'fastest', buffer: () => 'catch up latest', bufferNote: () => '* To cut off more than this number of comments for catching up latest ones.', translators: () => 'Domain specific terms', translatorsEmpty: () => 'No terms available now.', dictionary: () => 'Replacement dictionary', dictionaryNote: () => '[/source(RegExp)/, \'destination\', \'memo(optional)\'],... as Array', professional: () => '(for professional)', ng: () => 'NG words', ngNote: () => 'comma(,) separated list', reset: () => 'reset', cancel: () => 'Cancel', save: () => 'Save', dictionaryParseError: () => `Replacement dictionary error:\nrequired ${TEXTS.dictionaryNote()},\nor you can reset all preferences.`, resetConfirmation: () => `All preferences will be reset to defaults. Are you sure?`, }, ja: { scriptname: () => `配信コメント読み上げちゃん`, configs: () => `配信コメント読み上げちゃん 設定`, test: () => '試し読み', text: () => 'これはテストです ABC', speech: () => '読み上げの声', volume: () => '音量', pitch: () => '高さ', voice: () => '種類', fast: () => 'コメント混雑時', fastest: () => '速読み', buffer: () => '追いかけコメント数', bufferNote: () => '※これ以上古いコメントを切り捨てることで、読み上げがいつまでも追いつかなくなるのを防ぎます。', translators: () => '専門用語モード', translatorsEmpty: () => '専門用語が用意されていません。', dictionary: () => '置換辞書', dictionaryNote: () => '[/置換元(正規表現)/, \'置換先\', \'メモ(任意)\'],... の配列', professional: () => '(上級者向け)', ng: () => 'NGワード', ngNote: () => 'カンマ(,)区切りのリスト', reset: () => 'リセット', cancel: () => 'キャンセル', save: () => '保存', dictionaryParseError: () => `置換辞書の形式が正しくありません:\n${TEXTS.dictionaryNote()}にするか、\nまたは全ての設定値をリセットしてください。`, resetConfirmation: () => 'すべての設定が初期化されます。よろしいですか?', }, zh: { scriptname: () => `发布评论朗读`, configs: () => `发布评论阅读设置`, test: () => '试读', text: () => '这是测试ABC', speech: () => '朗读的声音', volume: () => '音量', pitch: () => '高度', voice: () => '种类', fast: () => '评论拥挤时', fastest: () => '速读', buffer: () => '追随评论数', bufferNote: () => '※通过舍弃更旧的评论,防止朗读永远跟不上。', translators: () => '术语模式', translatorsEmpty: () => '未提供专业术语', dictionary: () => '替换词典', dictionaryNote: () => '[/替换自(正则表达式)/, \'替换为\', \'注释(可选)\'],... 的数组。', professional: () => '(高级)', ng: () => 'NG字', ngNote: () => '以逗号(,)分隔的列表', reset: () => '重置', cancel: () => '取消', save: () => '保存', dictionaryParseError: () => `替换词典的格式不正确: \n${TEXTS.dictionaryNote()},或者\n将所有的设定值复位。`, resetConfirmation: () => '所有设置都将被初始化。可以吗?', }, }; const TEXTS = _TEXTS[USERLANGUAGE] || _TEXTS[USERLANGUAGE.substring(0, 2)] || _TEXTS.en; const _DICTIONARIES = { /* 置換元, 置換先, 説明(任意) */ en: { default: [ [/http:\/\/[^\s]+/, 'URL'], ], }, ja: { default: [ [/http:\/\/[^\s]+/, 'URL'], [/[88]{3,}/, 'パチパチパチ'], [/[ww]{3,}/, 'ワラワラワラ'], [/[ww]{2}/, 'ワラワラ'], [/[ww]$/, 'ワラ', '文末のみ1文字でも'], [/w/g, 'ワラ', '全角のみ1文字でも'], [/(.{1})\1{4,}/ug, '$1$1$1$1$1', '1文字の5回以上の繰り返しはカット'], [/(.{2})\1{3,}/ug, '$1$1$1$1', '2文字の4回以上の繰り返しはカット'], [/(.{3})\1{2,}/ug, '$1$1', '3文字の3回以上の繰り返しはカット'], [/(.{4,})\1{1,}/ug, '$1', '4文字以上の繰り返しはカット'], [/([あ-ん~])[~〜]/g, '$1ー', 'から => 長音'], [/はよ$/, 'ハヨ'], [/初見/, 'ショケン'], [/AbemaTV/, 'アベマティーヴィー'], [/Abema/, 'アベマ'], [/ニコ生/, 'ニコナマ'], ], nicolive: [ [/^(【広告貢#[0-9]位】)?(.+)さんが([0-9]+)ptニコニ広告しました(「(.+)」)?$/, '$1、$2さんが、$3ポイント、ニコニ広告しました。$4。'], [/^(【ニコニコ新市場】)「(.+)」が貼られました$/, '$1、$2、が貼られました'], ], } }; const DICTIONARIES = _DICTIONARIES[SITELANGUAGE] || _DICTIONARIES[SITELANGUAGE.substring(0, 2)] || _DICTIONARIES.en; const _TRANSLATORS = { en: { }, ja: { '将棋': (text) => { // 文字入力の変換用辞書として公開されているデータがあるが採用保留 // https://github.com/knu/imedic-shogi/blob/master/shogi.vje.txt const POSITIONS = [ [/[11一]/g, 'イチ'], [/[22二]/g, 'ニー'], [/[33三]/g, 'サン'], [/[44四]/g, 'ヨン'], [/[55五]/g, 'ゴー'], [/[66六]/g, 'ロク'], [/[77七]/g, 'ナナ'], [/[88八]/g, 'ハチ'], [/[99九]/g, 'キュー'], ]; const PIECES = [ [/王/, 'オー'], [/玉/, 'ギョク'], [/飛車/, 'ヒシャ'], [/飛/, 'ヒ'], [/角/, 'カク'], [/金/, 'キン'], [/銀/, 'ギン'], [/桂馬/, 'ケーマ'], [/桂/, 'ケー'], [/香/, 'キョー'], [/歩/, 'フ'], [/龍|竜/, 'リュー'], [/馬/, 'ウマ'], [/不成/, 'ナラズ'], [/成(?![ら-ろ])/, 'ナリ'], [/と/, 'ト'], [/同/, 'ドウ'], [/打(?![た-とっ])/, 'ウツ'], [/右/, 'ミギ'], [/左/, 'ヒダリ'], [/上/, 'アガル'], [/寄(?![ら-ろっ])/, 'ヨル'], [/引(?![か-こっ])/, 'ヒク'], [/直/, 'スグ'], ]; const MOVES = [{ regexp: /([1-91-9])([1-91-9一二三四五六七八九])([王玉飛車角金銀桂香歩龍竜馬成と同不打右左上寄引直]+)[あ-んっ+−\+\-]?/g, replacement: [...POSITIONS, ...PIECES], }, { regexp: /(?<![+\+−\-][0-9]*)([1-91-9])([1-91-9一二三四五六七八九])(?=[あ-ん指取成走入跳突叩攻守]|$)/g, replacement: [...POSITIONS], }, { regexp: /([王玉飛車角金銀桂香歩龍竜馬成と同不打右左上寄引直]{2,})[あ-んっ]?/g, replacement: [...PIECES], }]; const MODIFICATIONS = [ /* 固有名詞 - 人物 */ [/大山/g, 'オーヤマ'], [/中原/g, 'ナカハラ'], [/米長/g, 'ヨネナガ'], [/一二三/g, 'ヒフミ'], [/羽生/g, 'ハブ'], [/豊島/g, 'トヨシマ'], [/天彦/g, 'アマヒコ'], [/太地/g, 'タイチ'], [/高見/g, 'タカミ'], [/八代/g, 'ヤシロ'], [/光瑠/g, 'コール'], [/聡ちゃん/g, 'ソーチャン'], [/市代/g, 'イチヨ'], [/室谷/g, 'ムロヤ'], [/香奈/g, 'カナ'], [/貞升/g, 'サダマス'], [/香川/g, 'カガワ'], [/桂香(?=ちゃん)/g, 'ケーカ'], [/(K|K)太/ig, 'ケータ'], [/イトシン(TV|TV)/ig, 'イトシンティーヴィー'], /* 固有名詞 - 名称 */ [/朝日杯/, 'アサヒハイ'], [/NHK杯/, 'エネーチケーハイ'], [/JT杯/, 'ジェーティーハイ'], [/棋神/, 'キシン'], [/激指/, 'ゲキサシ'], [/elmo/, 'エルモ'], /* 用語 */ [/評価値/, 'ヒョーカチ'], [/候補手/, 'コーホシュ'], [/互角/, 'ゴカク'], [/AI/, 'エーアイ'], [/将棋星人/, 'ショーギセージン'], [/級位者/, 'キューイシャ'], [/先手|▲|☗/g, 'センテ'], [/後手|△|☖|▽|⛉/g, 'ゴテ'], [/一手/g, 'イッテ'], [/早指し/, 'ハヤザシ'], [/早逃げ/, 'ハヤニゲ'], [/最善手/, 'サイゼンシュ'], [/次善手/, 'ジゼンシュ'], [/疑問手/, 'ギモンシュ'], [/筋悪/, 'スジワル'], [/長手数/, 'チョーテスー'], [/余詰(め)?/, 'ヨヅメ'], [/合(い)?駒/, 'アイゴマ'], [/中合(い)?/, 'チューアイ'], [/[11一]筋/, 'イチスジ'], [/[22二]筋/, 'ニスジ'], [/([1-91-9一二三四五六七八九])冠/, '$1カン'], [/\s対\s/, ' タイ '], [/vs|vs/, 'ブイエス'], [/大盤/, 'オーバン'], [/昼休/, 'チューキュー'], [/夕休/, 'ユーキュー'], [/盤外戦/, 'バンガイセン'], [/中継/, 'チューケー'], [/上座/, 'カミザ'], [/下座/, 'シモザ'], /* 戦型 */ [/定跡(型|形)/, 'ジョーセキケー'], [/力戦(型|形)/, 'リキセンケー'], [/対抗(型|形)/, 'タイコーケー'], [/理想(型|形)/, 'リソーケー'], [/急戦/, 'キューセン'], [/戦型/, 'センケー'], [/右玉/, 'ミギギョク'], [/相居(飛車|ヒシャ)/, 'アイイビシャ'], [/相(掛|懸)(かり)?/, 'アイガカリ'], [/横歩取り/, 'ヨコフドリ'], [/居(飛車|ヒシャ)/, 'イビシャ'], [/振(り)?(飛車|ヒシャ)/, 'フリビシャ'], [/中(飛車|ヒシャ)/, 'ナカビシャ'], [/四間(飛車|ヒシャ)/, 'シケンビシャ'], [/四間/, 'シケン'], [/三間(飛車|ヒシャ)/, 'サンケンビシャ'], [/三間/, 'サンケン'], [/向(かい)?(飛車|ヒシャ)/, 'ムカイビシャ'], [/早石田/, 'ハヤイシダ'], [/角(換|替)わり/, 'カクガワリ'], [/角交換/, 'カクコーカン'], [/一手損/, 'イッテゾン'], /* 戦法 */ [/中座飛車/, 'チューザビシャ'], /* 囲い */ [/玉[型形]/, 'ギョクケー'], [/居玉/, 'イギョク'], [/中住まい/, 'ナカズマイ'], [/(舟|船)囲い/, 'フナガコイ'], [/(ビッグ|big)(4|4)/i, 'ビッグフォー'], [/左美濃/, 'ヒダリミノ'], [/高美濃/, 'タカミノ'], [/金無双/, 'キンムソー'], /* 駒(1文字は特に最後へ) */ [/大駒/, 'オーゴマ'], [/金駒/, 'カナゴマ'], [/小駒/, 'コゴマ'], [/玉頭/, 'ギョクトー'], [/王手(飛車|ヒシャ)/, 'オーテビシャ'], [/角頭/, 'カクトー'], [/角道/, 'カクミチ'], [/桂頭/, 'ケートー'], [/二歩/, 'ニフ'], [/と金/, 'とキン'], [/金底の歩/, 'キンゾコのフ'], [/玉/g, 'ギョク'],/*(?<!埼)*/ [/角/g, 'カク'], [/金/g, 'キン'],/*(?<!お)*/ [/桂馬/g, 'ケーマ'], [/桂/g, 'ケー'], [/香車/g, 'キョーシャ'], [/香(?![っらりるれろ])/g, 'キョー'], [/歩(?![いかきくけこ])/g, 'フ'], /* 評価値 */ [/[+\+]([0-9]+)/g, 'プラス$1'], [/[−\-]([0-9]+)/g, 'マイナス$1'], ]; /* 棋譜と符号 */ MOVES.forEach(p => { let tes = text.match(p.regexp); if(tes !== null) tes.forEach(te => { let yomi = te; p.replacement.forEach(p => yomi = yomi.replace(p[0], p[1])); text = text.replace(te, yomi); }); }); /* 用語 */ MODIFICATIONS.forEach(m => text = text.replace(m[0], m[1])); /* 完了 */ return text; }, }, }; const TRANSLATORS = _TRANSLATORS[SITELANGUAGE] || _TRANSLATORS[SITELANGUAGE.substring(0, 2)] || _TRANSLATORS.en; const UNKNOWNPITCHRATIO = .5;/* 不明コメントのピッチ係数 */ let sites = { abema: { id: 'abema', url: /^https:\/\/abema\.tv/, reverse: false, insertBefore: false, targets: { board: () => $('.com-a-OnReachTop > div'), settingAnchor: () => $('.com-tv-TVController__volume'), }, addedNodes: { name: (node) => null, content: (node) => node.querySelector('div > p > span'), read: [ [1.0, (node) => (node.querySelector('time[datetime]') !== null)], ], ignore: [], } }, bilibili: { id: 'bilibili', url: /^https:\/\/live\.bilibili\.com\/[0-9]+/, reverse: false, insertBefore: false, targets: { board: () => $('#chat-items'), settingAnchor: () => $('.icon-right-part'), }, addedNodes: { name: (node) => node.querySelector('.user-name'), content: (node) => node.querySelector('.danmaku-content'), read: [ [1.500, (node) => node.classList.contains('guard-level-1')], [1.250, (node) => node.classList.contains('guard-level-2')], [1.125, (node) => node.classList.contains('guard-danmaku')], [1.000, (node) => node.classList.contains('danmaku-item')], ], ignore: [ [0.0, (node) => node.classList.contains('system-msg')], [0.0, (node) => node.classList.contains('welcome-msg')], ], } }, douyu: { id: 'douyu', url: /^https:\/\/www\.douyu\.com\/.+/, reverse: false, insertBefore: false, targets: { board: () => $('#js-barrage-list'), settingAnchor: () => $('.ChatToolBar > *:last-child'), }, addedNodes: { name: (node) => node.querySelector('.Barrage-nickName'), content: (node) => node.querySelector('.Barrage-content'), read: [ [1.25, (node) => (node.querySelector('.Barrage-message') !== null)], [1.00, (node) => (node.querySelector('.Barrage-notice--normalBarrage') !== null)], ], ignore: [ [0.0, (node) => (node.querySelector('.Barrage-userEnter') !== null)], [0.0, (node) => (node.querySelector('.Barrage-notice') !== null)], ], } }, fc2: { id: 'fc2', url: /^https:\/\/live\.fc2\.com\/[0-9]+/, reverse: false, insertBefore: true, targets: { board: () => $('#js-commentListContainer'), settingAnchor: () => $('.chat_tab-control > *:first-child'), }, addedNodes: { name: (node) => node.querySelector('.js-commentUserName'), content: (node) => node.querySelector('.js-commentText'), read: [ [1.0, (node) => node.classList.contains('js-commentLine')], ], ignore: [], } }, huajiao: { id: 'huajiao', url: /^https:\/\/www\.huajiao\.com\/l\/[0-9]+/, reverse: false, insertBefore: true, targets: { board: () => $('.tt-msg-list'), settingAnchor: () => $('.tt-type-form'), }, addedNodes: { name: (node) => node.querySelector('.tt-msg-nickname'), content: (node) => node.querySelector('.tt-msg-content-h5'), read: [ [1.0, (node) => node.classList.contains('.tt-msg-message')], ], ignore: [], } }, huya: { id: 'huya', url: /^https:\/\/www\.huya\.com\/.+/, reverse: false, insertBefore: true, targets: { board: () => $('#chat-room__list'), settingAnchor: () => $('.room-chat-tools > *:first-child'), }, addedNodes: { name: (node) => node.querySelector('.name'), content: (node) => node.querySelector('.msg'), read: [ [1.25, (node) => (node.querySelector('.msg-nobleSpeak') !== null)], [1.00, (node) => (node.querySelector('.msg') !== null)], ], ignore: [ [0.0, (node) => (node.querySelector('.msg-nobleEnter') !== null)], ], } }, inke: { id: 'inke', url: /^https?:\/\/www\.inke\.cn\/live.+/, reverse: false, insertBefore: true, targets: { board: () => $('.comments_list > ul'), settingAnchor: () => $('.comments_box > input[type="text"]'), }, addedNodes: { name: (node) => node.querySelector('li > span'), content: (node) => node.querySelector('.comments_text') || node.querySelector('.comments_gift'), read: [ [1.0, (node) => (node.querySelector('img + span + span.comments_text') !== null)], [1.0, (node) => (node.querySelector('img + span + span.comments_gift') !== null)], ], ignore: [], }, }, line: { id: 'line', url: /^https:\/\/live\.line\.me\/channels\/[0-9]+\/broadcast\/[0-9]+/, reverse: false, insertBefore: false, targets: { board: () => $('[class*="Comment"] > div + div > [class*="Scroll"]'), settingAnchor: () => $('[class*="Notice"] > [class*="Desc"] > span'), }, addedNodes: { name: (node) => node.querySelector('[class*="Head"]'), content: (node) => node.querySelector('[class*="Heart"]') || node.querySelector('[class*="Desc"]') || node, read: [ [1.0, (node) => node.className.includes('Label')], [1.0, (node) => node.className.includes('Chat')], ], ignore: [], } }, nicolive: { id: 'nicolive', url: /^https:\/\/live[0-9]+\.nicovideo\.jp\/watch\/[a-z]+[0-9]+/, reverse: false, insertBefore: false, targets: { board: () => $('[class*="_comment-panel_"] [class*="_table_"]'), settingAnchor: () => $('[class*="_setting-button_"]'), }, addedNodes: { name: (node) => node.querySelector('[class*="_comment-author-name_"]'), content: (node) => node.querySelector('[class*="_comment-text_"]'), read: [ [1.0, (node) => (node.dataset.commentType === 'nicoad')], [1.0, (node) => (node.dataset.commentType === 'normal')], [0.9, (node) => (node.dataset.commentType === 'trialWatch')], [0.5, (node) => (node.dataset.commentType === 'operator')], ], ignore: [], } }, openrec: { id: 'openrec', url: /^https:\/\/www\.openrec\.tv\/live\/.+/, reverse: false, insertBefore: true, targets: { board: () => $('.chat-list-content'), settingAnchor: () => $('[class*="InputArea__ToolbarItem-"]'), }, addedNodes: { name: (node) => node.querySelector('[class*="UserName__Name-"]'), content: (node) => node.querySelector('.chat-content'), read: [ [1.0, (node) => node.className.includes('ChatList__CellContainer-')], ], ignore: [ [0.0, (node) => node.className.includes('system-chat')], ], } }, periscope: { id: 'periscope', url: /^https:\/\/www\.pscp\.tv\/w\/.+/, reverse: false, insertBefore: false, targets: { board: () => $('.Chat > div[style] > div[style]'), settingAnchor: () => $('.VideoOverlayRedesign-BottomBar-Right > *:last-child'), }, addedNodes: { name: (node) => node.querySelector('.CommentMessage-username'), content: (node) => node.querySelector('.CommentMessage-message'), read: [ [1.0, (node) => (node.querySelector('.CommentMessage') !== null)], ], ignore: [ [0.0, (node) => (node.querySelector('.ParticipantMessage') !== null)], ], } }, showroom: { id: 'showroom', url: /^https:\/\/www\.showroom-live\.com\/.+/, reverse: true, insertBefore: true, targets: { board: () => $('#room-comment-log-list'), settingAnchor: () => $('#js-room-head-other-select-box', e => e.parentNode), }, addedNodes: { name: (node) => node.querySelector('.comment-log-name'), content: (node) => node.querySelector('.comment-log-comment'), read: [ [1.0, (node) => node.classList.contains('commentlog-row')], ], ignore: [], } }, twitcasting: { id: 'twitcasting', url: /^https:\/\/twitcasting\.tv\/.+/, reverse: true, insertBefore: false, targets: { board: () => $('.tw-player-comment-list'), settingAnchor: () => $('#commentnumarea'), }, addedNodes: { name: (node) => node.querySelector('.tw-comment-item-name'), content: (node) => node.querySelector('.tw-comment-item-comment'), read: [ [1.0, (node) => node.className.includes('tw-comment-item')], ], ignore: [], } }, twitch: { id: 'twitch', url: /^https:\/\/www\.twitch\.tv/, reverse: false, insertBefore: true, targets: { board: () => $('[role="log"]'), settingAnchor: () => $('.chat-input__buttons-container [aria-describedby]'), }, addedNodes: { name: (node) => node.querySelector('.chat-author__display-name'), content: (node) => node.querySelector('.text-fragment'), read: [ [1.0, (node) => node.className.includes('chat-line__message')], ], ignore: [], } }, whowatch: { id: 'whowatch', url: /^https:\/\/whowatch\.tv\/viewer\/[0-9]+/, reverse: true, insertBefore: true, targets: { board: () => $('.normal-comment-list > div'), settingAnchor: () => $('.limit'), }, addedNodes: { name: (node) => node.querySelector('.user-name'), content: (node) => node.querySelector('.message'), read: [ [1.0, (node) => node.classList.contains('comment-box')], ], ignore: [], }, }, yizhibo: { id: 'yizhibo', url: /^https:\/\/www\.yizhibo\.com\/l\/.+/, reverse: false, insertBefore: true, targets: { board: () => $('#J_msglist'), settingAnchor: () => $('#J_send_danmu'), }, addedNodes: { name: (node) => node.querySelector('.nickname'), content: (node) => node.querySelector('.content'), read: [ [1.0, (node) => node.classList.contains('msg_1')], ], ignore: [ [0.0, (node) => node.classList.contains('msg_2')], [0.0, (node) => node.classList.contains('msg_3')], ], }, }, youtube: { id: 'youtube', url: /^https:\/\/www\.youtube\.com\/live_chat/, reverse: false, insertBefore: true, targets: { board: () => $('#item-offset > #items'), settingAnchor: () => $('yt-live-chat-header-renderer yt-icon-button'), }, addedNodes: { name: (node) => node.querySelector('#author-name'), content: (node) => node.querySelector('#message'), read: [ [1.5, (node) => (node.localName === 'yt-live-chat-paid-message-renderer'), 'スパチャ'], [1.0, (node) => node.classList.contains('yt-live-chat-item-list-renderer')], ], ignore: [ [0.0, (node) => (node.localName === 'yt-live-chat-viewer-engagement-message-renderer')], ], }, }, yy: { id: 'yy', url: /^https:\/\/www\.yy\.com\/[0-9]+\/[0-9]+/, reverse: false, insertBefore: false, targets: { board: () => $('.chatroom-list'), settingAnchor: () => $('.chat-room-ft'), }, addedNodes: { name: (node) => node.querySelector('.nickname'), content: (node) => node.querySelector('.nickname + span'), read: [ [1.0, (node) => node.classList.contains('phizbox')], ], ignore: [], }, }, }; class Configs{ constructor(configs){ Configs.DICTIONARY = [...DICTIONARIES.default, ...(DICTIONARIES[site.id] || [])]; Configs.TRANSLATORS = Object.keys(TRANSLATORS); Configs.PROPERTIES = { text: {type: 'string', default: TEXTS.text()}, volume: {type: 'int', default: 25},/* 0-100 => 0.0-1.0 */ pitch: {type: 'int', default: 100},/* 0-200 => 0.0-2.0 */ voice: {type: 'string', default: ''},/* name of voice */ fastest: {type: 'int', default: 150},/* 100-250 => 1.0-2.5 */ buffer: {type: 'int', default: 5},/* 1- 25 */ dictionary: {type: 'array', default: Configs.DICTIONARY},/* replacement pairs */ translators: {type: 'array', default: []},/* name of translators */ ngs: {type: 'array', default: []},/* ng word list */ }; this.data = this.read(configs || {}); } read(configs){ let newConfigs = {}; Object.keys(Configs.PROPERTIES).forEach(key => { if(configs[key] === undefined) return newConfigs[key] = Configs.PROPERTIES[key].default; if(key === 'dictionary') return newConfigs[key] = configs[key].map(entry => { if(entry[0] instanceof RegExp) return entry; let parts = entry[0].match(/^\/(.*)\/([a-z]*)$/); if(parts === null) entry[0] = new RegExp(entry[0]); else entry[0] = new RegExp(parts[1], parts[2]); return entry; }); switch(Configs.PROPERTIES[key].type){ case('bool'): return newConfigs[key] = (configs[key]) ? 1 : 0; case('int'): return newConfigs[key] = parseInt(configs[key]); case('float'): return newConfigs[key] = parseFloat(configs[key]); default: return newConfigs[key] = configs[key]; } }); return newConfigs; } toJSON(){ let json = {}; Object.keys(this.data).forEach(key => { switch(key){ case('dictionary'): return json[key] = this.data[key].map(entry => { if(entry[2] === undefined) return [entry[0].toString(), entry[1]]; else return [entry[0].toString(), entry[1], entry[2]]; }); default: return json[key] = this.data[key]; } }); return json; } parseDictionaryString(string){ let wrapper = string.trim().match(/^\[([\S\s]+)\]$/); if(wrapper === null) return false; let entries = wrapper[1].trim().match(/\[(.+)\]\s*,/g); if(entries === null) return false; let lines = wrapper[1].trim().match(/.{3,}(\n|$)/g); if(lines.length !== entries.length) return false; let dictionary = []; for(let i = 0; entries[i]; i++){ let parts = entries[i].trim().match(/\[\s*\/(.*)\/([a-z]*)\s*,\s*'(.*?[^\\])'(?:\s*,\s*'(.*[^\\])')?\s*\]\s*,/); if(parts === null) return false; dictionary[i] = [new RegExp(parts[1], parts[2]), parts[3]]; if(parts[4] !== undefined) dictionary[i].push(parts[4]); } return dictionary; } parseNgsString(string){ if(string.trim() === '') return []; else return string.trim().split(',').map(s => s.trim()); } get text(){return this.data.text;} get volume(){return this.data.volume / 100;} get pitch(){return this.data.pitch / 100;} get voice(){return this.data.voice;} get fastest(){return this.data.fastest / 100;} get buffer(){return this.data.buffer;} get dictionary(){return this.data.dictionary;} get translators(){return this.data.translators;} get ngs(){return this.data.ngs;} get dictionaryString(){ let dictionary = this.data.dictionary, string = ''; let quote = (s) => '\'' + s.replace('\'', '\\\'') + '\''; dictionary.forEach(entry => { string += ' ['; string += entry[0].toString(); string += ', '; string += quote(entry[1]); if(entry[2] !== undefined){ string += ', '; string += quote(entry[2]); } string += '],\n'; }); return '[\n' + string + ']'; } get ngsString(){ return this.data.ngs.join(','); } } class Speaker{ constructor(configs){ Speaker.TRANSLATORS = TRANSLATORS; this.speechSynthesis = speechSynthesis; this.voices = this.getVoices(); this.configs = configs; this.queue = []; this.interval = 250; } getVoices(){ let voices = {}, array = this.speechSynthesis.getVoices(); if(array.length) array.forEach(v => voices[v.name] = v); else this.speechSynthesis.addEventListener('voiceschanged', () => this.voices = this.getVoices()); return voices; } request(text, ratio, node){ let utterance = new SpeechSynthesisUtterance(this.modify(text)); utterance.pitch = this.configs.pitch * ratio; utterance.node = node; this.queue.push(utterance); if(this.queue.length === 1){/* 2個以上あるならすでに連続発話が始まっている */ setTimeout(() => this.speak(), 0);/* 一度に複数リクエストを受け取った際に合計数をrateに反映させたい */ } } modify(text){ this.configs.dictionary.forEach(d => text = text.replace(d[0], d[1])); this.configs.translators.forEach(key => text = Speaker.TRANSLATORS[key](text)); return text; } speak(){ if(this.queue.length === 0) return; if(this.configs.ngs.some(ng => this.queue[0].text.includes(ng))) return this.queue.shift(), this.speak(); if(this.queue.length > this.configs.buffer) this.queue = this.queue.slice(-this.configs.buffer);/*古いものは切り捨てる*/ let utterance = this.queue[0]; utterance.volume = this.configs.volume; utterance.rate = 1 + ((this.queue.length - 1) / ((this.configs.buffer - 1) || 1))*(this.configs.fastest - 1); utterance.voice = this.voices[this.configs.voice]; utterance.node.dataset.speaking = 'true'; utterance.addEventListener('end', (e) => { utterance.node.dataset.speaking = 'false'; this.queue.shift(); if(this.queue.length) setTimeout(() => this.speak(), this.interval); }); log(utterance); this.speechSynthesis.speak(utterance); } cancel(){ this.queue = []; this.speechSynthesis.cancel(); } test(text, volume, pitch, voice, rate){ let utterance = new SpeechSynthesisUtterance(this.modify(text)); utterance.volume = volume; utterance.pitch = pitch; utterance.voice = this.voices[voice]; utterance.rate = rate; this.speechSynthesis.speak(utterance); log('Test:', text, '=>', utterance.text); } } let html, elements = {}, timers = {}, site, panels, configs, speaker; let core = { initialize: function(){ html = document.documentElement; if(html){ html.classList.add(SCRIPTID); core.site(); } }, site: function(){ site = sites[Object.keys(sites).find(key => sites[key].url.test(location.href))]; if(site === undefined) return log('Doesn\'t match any sites:', location.href); core.read(); core.observeElements(); core.addStyle(); core.addStyle(site.id); core.addStyle('stylePanels', window.top.document); core.export(); }, observeElements: function(){ /* 開閉する要素に対応。結局インターバルがいちばん負荷が軽い */ setInterval(function(){ new Promise(function(resolve, reject){ if(elements.settingAnchor && elements.settingAnchor.isConnected) return resolve(); elements.settingAnchor = site.targets.settingAnchor(); if(elements.settingAnchor){ core.configs.createButton(); log("Configs button ready."); return resolve(); }else{ return reject(); } }).then(() => { if(elements.board && elements.board.isConnected) return; elements.board = site.targets.board(); if(elements.board){ core.observeBoard(elements.board); log("Board ready."); } }); }, 1000); }, read: function(){ panels = new Panels(window.top.document.body.appendChild(createElement(core.html.panels()))); configs = new Configs(Storage.read('configs') || {}); speaker = new Speaker(configs); }, observeBoard: function(board){ let configButton = elements.configButton; let isNewer = function(node){ if(site.reverse){ for(let i = 0; board.children[i]; i++){ if(node === board.children[i]) return true; if(i >= configs.buffer) return false; } }else{ for(let i = board.children.length - 1; board.children[i]; i--){ if(node === board.children[i]) return true; if(board.children.length - i >= configs.buffer) return false; } } }; observe(board, function(records){ //log(records); if(configButton.classList.contains('active') === false) return; if(site.reverse) records.reverse(); records.forEach(r => { r.addedNodes.forEach(n => { if(isNewer(n) === false) return;/*最後のbuffer個数分でなければ無視してよい*/ let name = site.addedNodes.name(n); let content = site.addedNodes.content(n); if(content === null || content.textContent.trim() === '') return; let read = site.addedNodes.read.find(r => r[1](n)); if(read) return speaker.request(content.textContent, read[0], content); else if(site.addedNodes.ignore.some(i => i[1](n))) return; else return speaker.request(content.textContent, UNKNOWNPITCHRATIO, content); }); }); }); }, configs: { createButton: function(){ let anchor = elements.settingAnchor, before = site.insertBefore; let node, configButton = elements.configButton = createElement(core.html.configButton(core.html.configButtonProperties[site.id])); if(core.html.configButtonWrappers[site.id]){ node = createElement(core.html.configButtonWrappers[site.id]()); node.appendChild(configButton); }else{ node = configButton; } node.className = [node.className, anchor.className].join(' '); configButton.addEventListener('click', function(e){ configButton.classList.toggle('active'); if(configButton.classList.contains('active') === false) speaker.cancel(); }); configButton.addEventListener('contextmenu', function(e){ e.preventDefault(); panels.toggle('configs'); }); anchor.parentNode.insertBefore(node, (before ? anchor : anchor.nextElementSibling)); core.configs.createPanel(); }, createPanel: function(){ let panel = createElement(core.html.configPanel()), itemElements = panel.querySelectorAll('[name]'), items = {}; Array.from(itemElements).forEach(e => items[e.name] = e); /* リセット */ panel.querySelector('button.reset').addEventListener('click', function(e){ if(confirm(TEXTS.resetConfirmation())){ panels.hide('configs'); configs = new Configs({}); core.configs.createPanel(); panels.show('configs'); } }); /* 試し読み */ let normal = panel.querySelector('button.normal'), fast = panel.querySelector('button.fast'); let getValue = (node) => (parseInt(node.value) / 100); normal.addEventListener('click', function(e){ speaker.test(items.text.value, getValue(items.volume), getValue(items.pitch), items.voice.value, 1); }); fast.addEventListener('click', function(e){ speaker.test(items.text.value, getValue(items.volume), getValue(items.pitch), items.voice.value, getValue(items.fastest)); }); /* 声 */ let defaultVoice = Object.keys(speaker.voices).find(key => speaker.voices[key].default) || Object.keys(speaker.voices).find(key => speaker.voices[key].lang.startsWith(navigator.language)); let currentVoice = speaker.voices[configs.voice || defaultVoice], languages = [], voices = []; Object.keys(speaker.voices).forEach(key => { if(languages.includes(speaker.voices[key].lang) === false) languages.push(speaker.voices[key].lang); voices.push(key); }); languages.sort().forEach(l => { let option = createElement(core.html.option(l)); if(l === currentVoice.lang) option.selected = true; items.language.appendChild(option); }); voices.sort().forEach(v => { let option = createElement(core.html.option(v)); if(speaker.voices[v].lang !== currentVoice.lang) option.classList.add('hidden'); if(v === currentVoice.name) option.selected = true; items.voice.appendChild(option); }); items.language.addEventListener('change', function(e){ Array.from(items.voice.children).reverse().forEach(o => { if(speaker.voices[o.value].lang === e.target.value){ o.classList.remove('hidden'); o.selected = true; } else o.classList.add('hidden'); }); }); /* 専門用語モード */ let translatorTemplate = createElement(core.html.checkbox('translators', 'template')), translatorsEmpty = panel.querySelector('.translatorsEmpty'); items.translators = []; Object.keys(TRANSLATORS).forEach(key => { let label = translatorTemplate.cloneNode(true), input = label.querySelector('input[type="checkbox"]'); label.dataset.translator = key; input.value = key; input.checked = configs.translators.some(t => (t === key)); translatorsEmpty.parentNode.insertBefore(label, translatorsEmpty.parentNode.firstElementChild); items.translators.push(input); }); /* キャンセル */ panel.querySelector('button.cancel').addEventListener('click', function(e){ panels.hide('configs'); core.configs.createPanel();/*クリアしておく*/ }); /* 保存 */ panel.querySelector('button.save').addEventListener('click', function(e){ let dictionary = configs.parseDictionaryString(items.dictionary.value); if(dictionary === false) return alert(TEXTS.dictionaryParseError()); configs = new Configs({ text: items.text.value, volume: items.volume.value, pitch: items.pitch.value, voice: items.voice.value, fastest: items.fastest.value, buffer: items.buffer.value, translators: Array.from(items.translators).filter(t => t.checked).map(t => t.value), dictionary: dictionary, ngs: configs.parseNgsString(items.ngs.value), }); speaker.cancel(); speaker = new Speaker(configs); Storage.save('configs', configs.toJSON()); panels.hide('configs'); core.configs.createPanel();/*クリアしておく*/ }); /* iframeだけ閉じられる場合にパネルが取り残されないようにする */ window.addEventListener('unload', function(e){ panels.hide('configs'); core.configs.createPanel();/*クリアしておく*/ }, {once: true}); panels.add('configs', panel); }, }, export: function(){ if(DEBUG){ const ratio = 1, node = document.createElement('span'); window.say = function(text){ speaker.request(text, ratio, node); }; } }, addStyle: function(name = 'style', d = document){ if(core.html[name] === undefined) return; let style = createElement(core.html[name]()); d.head.appendChild(style); if(elements[name] && elements[name].isConnected) d.head.removeChild(elements[name]); elements[name] = style; }, html: { configButtonWrappers: { showroom: () => `<li></li>`, }, configButtonProperties: { nicolive: 'aria-label', }, configButton: (property = 'title') => ` <button id="${SCRIPTID}-config-button" ${property}="${TEXTS.scriptname()}"> <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 330 330" xml:space="preserve"> <g id="XMLID_797_"> <path id="XMLID_798_" d="M164.998,210c35.887,0,65.085-29.195,65.085-65.12l-0.204-80c0-35.776-29.105-64.88-64.881-64.88 c-35.773,0-64.877,29.104-64.877,64.843l-0.203,80.076C99.918,180.805,129.112,210,164.998,210z"/> <path id="XMLID_799_" d="M280.084,154.96c0-8.285-6.717-15-15-15c-8.284,0-15,6.715-15,15c0,46.732-37.878,84.773-84.546,85.067 c-0.181-0.007-0.357-0.027-0.54-0.027c-0.184,0-0.359,0.02-0.541,0.027c-46.664-0.293-84.541-38.335-84.541-85.067 c0-8.285-6.717-15-15-15c-8.284,0-15,6.715-15,15c0,58.372,43.688,106.731,100.082,114.104V300H117c-8.284,0-15,6.716-15,15 s6.716,15,15,15h96.002c8.283,0,15-6.716,15-15s-6.717-15-15-15h-33.004v-30.936C236.395,261.69,280.084,213.332,280.084,154.96z"/> </g> </svg> </button> `, configPanel: () => ` <div class="panel" id="${SCRIPTID}-config-panel" data-order="1"> <h1> <button class="reset" title="${TEXTS.reset()}"> <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 1000 1000" enable-background="new 0 0 1000 1000" xml:space="preserve"> <metadata> Svg Vector Icons : http://www.onlinewebfonts.com/icon </metadata> <g><path d="M500,10v392l196-196L500,10z"/><path d="M500,990C271.3,990,85.1,803.8,85.1,575.1c0-228.7,186.2-414.9,414.9-414.9v91.5c-176.4,0-323.4,143.7-323.4,323.4c0,179.7,143.7,323.4,323.4,323.4c179.7,0,323.4-143.7,323.4-323.4h91.5C914.9,803.8,728.7,990,500,990z"/></g> </svg> </button> ${TEXTS.configs()} </h1> <fieldset> <legend>${TEXTS.test()}</legend> <p class="property"><input type="text" name="text" value="${configs.data.text}"><button class="normal">▶</button><button class="fast">▶▶</button></p> </fieldset> <fieldset> <legend>${TEXTS.speech()}</legend> <p class="property"><label for="config-volume">${TEXTS.volume()}<small>(0-100%)</small>:</label><input type="number" name="volume" id="config-volume" value="${configs.data.volume}" min="0" max="100" step="5"></p> <p class="property"><label for="config-pitch" >${TEXTS.pitch()}<small>(0-200%)</small>: </label><input type="number" name="pitch" id="config-pitch" value="${configs.data.pitch}" min="0" max="200" step="10"></p> <p class="property"><label for="config-voice" >${TEXTS.voice()}:</label><select name="language"></select><select name="voice" id="config-voice"></select></p> </fieldset> <fieldset> <legend>${TEXTS.fast()}</legend> <p class="property"><label for="config-fastest">${TEXTS.fastest()}<small>(100-250%)</small>: </label><input type="number" name="fastest" id="config-fastest" value="${configs.data.fastest}" min="100" max="250" step="10"></p> <p class="property"><label for="config-buffer" title="${TEXTS.bufferNote()}">${TEXTS.buffer()}<sup>※</sup>:</label><input type="number" name="buffer" id="config-buffer" value="${configs.data.buffer}" min="1" max="25" step="1"></p> </fieldset> <fieldset> <legend>${TEXTS.translators()}</legend> <p class="property"><span class="translatorsEmpty">${TEXTS.translatorsEmpty()}</span></p> </fieldset> <fieldset> <legend>${TEXTS.dictionary()}<small>${TEXTS.professional()}</small></legend> <p class="property"><textarea name="dictionary" id="config-dictionary">${configs.dictionaryString}</textarea></p> <p class="note">${TEXTS.dictionaryNote()}</p> </fieldset> <fieldset> <legend>${TEXTS.ng()}</legend> <p class="property"><textarea name="ngs" id="config-ngs">${configs.ngsString}</textarea></p> <p class="note">${TEXTS.ngNote()}</p> </fieldset> <p class="buttons"><button class="cancel">${TEXTS.cancel()}</button><button class="save primary">${TEXTS.save()}</button></p> </div> `, option: (value) => `<option value="${value}">${value}</option>`, checkbox: (key, value) => `<label data-${key}="${value}"><input type="checkbox" name="${key}"></label>`, panels: () => `<div class="panels" id="${SCRIPTID}-panels" data-panels="0"></div>`, stylePanels: () => ` <style type="text/css"> /* 設定パネル(共通) */ #${SCRIPTID}-panels *{ font-size: 14px; line-height: 20px; padding: 0; margin: 0; } #${SCRIPTID}-panels{ font-family: Arial, sans-serif; position: fixed; width: 100%; height: 100%; top: 0; left: 0; overflow: hidden; pointer-events: none; cursor: default; z-index: 99999; } #${SCRIPTID}-panels div.panel{ position: absolute; max-height: 100%;/*小さなウィンドウに対応*/ overflow: auto; left: 50%; bottom: 50%; transform: translate(-50%, 50%); background: rgba(0,0,0,.75); transition: 250ms; padding: 5px 0; pointer-events: auto; } #${SCRIPTID}-panels div.panel.hidden{ bottom: 0; transform: translate(-50%, 100%) !important; display: block !important; } #${SCRIPTID}-panels div.panel.hidden *{ animation: none !important;/*CPU負荷軽減*/ } #${SCRIPTID}-panels h1, #${SCRIPTID}-panels h2, #${SCRIPTID}-panels h3, #${SCRIPTID}-panels h4, #${SCRIPTID}-panels legend, #${SCRIPTID}-panels ul, #${SCRIPTID}-panels ol, #${SCRIPTID}-panels dl, #${SCRIPTID}-panels p{ color: white; padding: 2px 10px; vertical-align: baseline; } #${SCRIPTID}-panels legend ~ p, #${SCRIPTID}-panels legend ~ ul, #${SCRIPTID}-panels legend ~ ol, #${SCRIPTID}-panels legend ~ dl{ padding-left: calc(10px + 14px); } #${SCRIPTID}-panels header{ display: flex; } #${SCRIPTID}-panels header h1{ flex: 1; } #${SCRIPTID}-panels fieldset{ border: none; } #${SCRIPTID}-panels fieldset > p{ display: flex; align-items: center; } #${SCRIPTID}-panels fieldset > p.property:hover{ background: rgba(255,255,255,.125); } #${SCRIPTID}-panels fieldset > p.property > label{ flex: 1; } #${SCRIPTID}-panels fieldset > p.property > input, #${SCRIPTID}-panels fieldset > p.property > textarea, #${SCRIPTID}-panels fieldset > p.property > select{ color: black; background: white; padding: 1px 2px; } #${SCRIPTID}-panels fieldset > p.property > input, #${SCRIPTID}-panels fieldset > p.property > button{ box-sizing: border-box; height: 20px; } #${SCRIPTID}-panels fieldset small{ font-size: 12px; margin: 0 0 0 .25em; } #${SCRIPTID}-panels fieldset sup, #${SCRIPTID}-panels fieldset p.note{ font-size: 10px; line-height: 14px; opacity: .75; } #${SCRIPTID}-panels div.panel > p.buttons{ text-align: right; padding: 5px 10px; } #${SCRIPTID}-panels div.panel > p.buttons button{ line-height: 1.4; width: 120px; padding: 5px 10px; margin-left: 10px; border-radius: 5px; color: rgba(255,255,255,1); background: rgba(64,64,64,1); border: 1px solid rgba(255,255,255,1); cursor: pointer; } #${SCRIPTID}-panels div.panel > p.buttons button.primary{ font-weight: bold; background: rgba(0,0,0,1); } #${SCRIPTID}-panels div.panel > p.buttons button:hover, #${SCRIPTID}-panels div.panel > p.buttons button:focus{ background: rgba(128,128,128,1); } #${SCRIPTID}-panels .template{ display: none !important; } /* 設定パネル */ #${SCRIPTID}-config-panel{ width: 320px; } #${SCRIPTID}-config-panel button.reset{ float: right; font-size: 20px; color: white; background: black; border: 1px solid #666; border-radius: 5px; width: 1em; height: 1em; cursor: pointer; } #${SCRIPTID}-config-panel button.reset:hover{ background: #333; } #${SCRIPTID}-config-panel button.reset svg{ fill: white; width: 100%; height: 100%; padding: 2px; box-sizing: border-box; } #${SCRIPTID}-config-panel input[type="number"]{ width: 4em; } #${SCRIPTID}-config-panel input[name="text"]{ border: 1px solid #999; border-radius: 5px 0 0 5px; height: 24px; flex: 1; } #${SCRIPTID}-config-panel input[name="text"] ~ button{ font-size: 10px; white-space: nowrap; color: white; background: #000; border: 1px solid #666; border-left: none; width: 4em; height: 24px; padding: 0 1em; cursor: pointer; } #${SCRIPTID}-config-panel input[name="text"] ~ button.fast{ border-radius: 0 5px 5px 0; } #${SCRIPTID}-config-panel input[name="text"] ~ button:hover{ background: #333; } #${SCRIPTID}-config-panel select#config-voice{ max-width: 120px; } #${SCRIPTID}-config-panel option.hidden{ display: none; } #${SCRIPTID}-config-panel label[data-translator]{ background: #333; border: 1px solid #666; border-radius: 5px; padding: 2px 5px; flex: 0 !important; white-space: nowrap; cursor: pointer; } #${SCRIPTID}-config-panel label[data-translator]:hover{ background: #444; } #${SCRIPTID}-config-panel label[data-translator]::after{ content: attr(data-translator); margin-left: 5px; } #${SCRIPTID}-config-panel label[data-translator] input{ cursor: pointer; } #${SCRIPTID}-config-panel .translatorsEmpty{ opacity: .75; } #${SCRIPTID}-config-panel label + .translatorsEmpty{ display: none; } #${SCRIPTID}-config-panel textarea{ width: 100%; height: 40px; font-family: monospace; } </style> `, style: () => ` <style type="text/css"> /* 設定ボタン */ button#${SCRIPTID}-config-button{ background: transparent; border: none; padding: 0; margin: 0; cursor: pointer; transition: 125ms; } button#${SCRIPTID}-config-button svg{ fill: #666; } button#${SCRIPTID}-config-button:hover svg{ fill: #999; } button#${SCRIPTID}-config-button.active svg{ fill: #f00; } button#${SCRIPTID}-config-button.active:hover svg{ fill: #f33; } /* 読み上げコメント */ [data-speaking="true"]{ position: relative !important; overflow: visible !important; } [data-speaking="true"]::after/*公式がbeforeを使っていても干渉しない*/{ font-family: Arial, sans-serif; content: "●"; color: red; font-size: 100%; position: absolute; left: -.125em; top: 50%; transform: translate(-100%, -50%); animation: ${SCRIPTID}-blink 1000ms ease 0ms infinite alternate forwards; } @keyframes ${SCRIPTID}-blink{ 50%{opacity: .5} } </style> `, abema: () => ` <style type="text/css"> button#${SCRIPTID}-config-button{ width: 40px; height: 40px; } button#${SCRIPTID}-config-button svg{ width: 24px; height: 24px; transform: translateY(7px); fill: #ccc; } button#${SCRIPTID}-config-button:hover svg{ fill: #fff; } button#${SCRIPTID}-config-button.active svg{ fill: #f00; } </style> `, bilibili: () => ` <style type="text/css"> button#${SCRIPTID}-config-button{ width: 20px; height: 20px; transform: translateY(1px); vertical-align: middle; } button#${SCRIPTID}-config-button::before{ display: none; } [data-speaking="true"]{ position: static !important; } [data-speaking="true"]::after{ left: .25em; } </style> `, douyu: () => ` <style type="text/css"> button#${SCRIPTID}-config-button{ width: 20px; height: 20px; transform: translate(-5px, calc(-100% - 5px)); vertical-align: middle; } [data-speaking="true"]{ position: static !important; } [data-speaking="true"]::after{ left: .625em; } </style> `, fc2: () => ` <style type="text/css"> button#${SCRIPTID}-config-button{ width: 42px; height: 38px; } button#${SCRIPTID}-config-button svg{ width: 24px; height: 24px; transform: translateY(1px); } [data-speaking="true"]::after{ left: .5em; } .js-commentLine{ position: relative; } .js-commentText{ position: static !important; } </style> `, huajiao: () => ` <style type="text/css"> button#${SCRIPTID}-config-button{ width: 30px; height: 30px; position: absolute; left: 100%; top: 0; transform: translate(-100%,-100%); } button#${SCRIPTID}-config-button svg{ width: 24px; height: 24px; transform: translateY(1px); } .tt-msg-message{ position: relative; } [data-speaking="true"]{ position: static !important; } [data-speaking="true"]::after{ left: 1.25em; } </style> `, huya: () => ` <style type="text/css"> button#${SCRIPTID}-config-button{ width: 22px; height: 22px; transform: translateY(1px); vertical-align: middle; float: left; margin-right: 10px; } button#${SCRIPTID}-config-button::before{ display: none; } .J_msg{ position: relative; } [data-speaking="true"]{ position: static !important; } [data-speaking="true"]::after{ left: .625em; } </style> `, inke: () => ` <style type="text/css"> button#${SCRIPTID}-config-button{ width: 36px; height: 36px; position: absolute; left: 100%; top: 0; transform: translate(calc(-100% - 10px), -100%) } button#${SCRIPTID}-config-button svg{ width: 24px; height: 24px; transform: translateY(1px); } .comments_list li{ position: relative; } [data-speaking="true"]{ position: static !important; } [data-speaking="true"]::after{ left: calc(28px + .65em); } </style> `, line: () => ` <style type="text/css"> button#${SCRIPTID}-config-button{ width: 40px; height: 40px; float: right; } button#${SCRIPTID}-config-button svg{ width: 24px; height: 24px; transform: translateY(1px); } #${SCRIPTID}-config-panel legend{ position: static; width: auto; height: auto; } [class*="Chat"] [data-speaking="true"]{ position: static !important; } [class*="Chat"] [data-speaking="true"]::after{ left: 1em; } [class*="Label"][data-speaking="true"]::after{ left: 0em; } </style> `, nicolive: () => ` <style type="text/css"> button#${SCRIPTID}-config-button{ width: 32px; height: 36px; } button#${SCRIPTID}-config-button svg{ width: 20px; height: 20px; transform: translateY(1px); } </style> `, openrec: () => ` <style type="text/css"> button#${SCRIPTID}-config-button{ width: 2.2rem; height: 2.2rem; margin-right: 1rem; } .chat-content[data-speaking="true"]{ position: static !important; } </style> `, periscope: () => ` <style type="text/css"> button#${SCRIPTID}-config-button{ width: 32px; height: 32px; margin-left: 10px; background-color: rgba(255, 255, 255, 0.2); border-radius: 32px; } button#${SCRIPTID}-config-button svg{ width: 20px; height: 20px; } .CommentMessage-body, [data-speaking="true"]{ position: static !important; } </style> `, showroom: () => ` <style type="text/css"> button#${SCRIPTID}-config-button{ width: 60px; height: 50px; } button#${SCRIPTID}-config-button svg{ width: 28px; height: 28px; transform: translateY(2px); } </style> `, twitcasting: () => ` <style type="text/css"> button#${SCRIPTID}-config-button{ width: 2em; height: 2em; margin-left: .5em; } #${SCRIPTID}-config-panel legend{ border: none; width: auto; } #${SCRIPTID}-config-panel input, #${SCRIPTID}-config-panel select{ width: auto; } </style> `, twitch: () => ` <style type="text/css"> .chat-input__buttons-container > div > .tw-relative > div{ display: flex; } button#${SCRIPTID}-config-button{ width: 3rem; height: 3rem; padding: .4rem; } button#${SCRIPTID}-config-button > svg{ width: 3rem; height: 3rem; position: relative; top: -.4rem; } #${SCRIPTID}-config-panel button{ text-align: center; } .chat-line__message{ position: relative; } .chat-line__message [data-speaking="true"]{ position: static !important; } .chat-line__message [data-speaking="true"]::after{ left: -5px; } </style> `, whowatch: () => ` <style type="text/css"> button#${SCRIPTID}-config-button{ width: 36px; height: 36px; position: absolute; left: 0; bottom: 0; } button#${SCRIPTID}-config-button svg{ width: 32px; height: 32px; transform: translateY(4px); } form .row{ position: relative; } [data-speaking="true"]{ position: static !important; } </style> `, yizhibo: () => ` <style type="text/css"> button#${SCRIPTID}-config-button{ width: 30px; height: 30px; position: absolute; left: 100%; top: 0; transform: translate(-100%,-100%); } button#${SCRIPTID}-config-button svg{ width: 24px; height: 24px; transform: translateY(1px); } .msg_1{ overflow: visible !important; } [data-speaking="true"]{ position: static !important; } </style> `, youtube: () => ` <style type="text/css"> button#${SCRIPTID}-config-button{ width: 40px; height: 40px; } button#${SCRIPTID}-config-button svg{ width: 20px; height: 20px; transform: translateY(1px); } yt-live-chat-text-message-renderer #content{ position: relative !important; } yt-live-chat-text-message-renderer [data-speaking="true"]{ position: static !important; } paper-tooltip #tooltip{ white-space: nowrap; } </style> `, yy: () => ` <style type="text/css"> button#${SCRIPTID}-config-button{ width: 30px; height: 30px; position: absolute; left: 100%; top: 0; transform: translate(calc(-100% - 5px), calc(-100% - 5px)); } button#${SCRIPTID}-config-button svg{ width: 24px; height: 24px; transform: translateY(1px); } ul.chatroom-list > li{ position: relative; } [data-speaking="true"]{ position: static !important; } [data-speaking="true"]::after{ left: .5em; } </style> `, }, }; const setTimeout = window.setTimeout.bind(window), clearTimeout = window.clearTimeout.bind(window), setInterval = window.setInterval.bind(window), clearInterval = window.clearInterval.bind(window), requestAnimationFrame = window.requestAnimationFrame.bind(window), requestIdleCallback = window.requestIdleCallback.bind(window); const alert = window.alert.bind(window), confirm = window.confirm.bind(window), prompt = window.prompt.bind(window), getComputedStyle = window.getComputedStyle.bind(window), fetch = window.fetch.bind(window); if(!('isConnected' in Node.prototype)) Object.defineProperty(Node.prototype, 'isConnected', {get: function(){return document.contains(this)}}); class Storage{ static key(key){ return (SCRIPTID) ? (SCRIPTID + '-' + key) : key; } static save(key, value, expire = null){ key = Storage.key(key); localStorage[key] = JSON.stringify({ value: value, saved: Date.now(), expire: expire, }); } static read(key){ key = Storage.key(key); if(localStorage[key] === undefined) return undefined; let data = JSON.parse(localStorage[key]); if(data.value === undefined) return data; if(data.expire === undefined) return data; if(data.expire === null) return data.value; if(data.expire < Date.now()) return localStorage.removeItem(key); return data.value; } static delete(key){ key = Storage.key(key); delete localStorage.removeItem(key); } static saved(key){ key = Storage.key(key); if(localStorage[key] === undefined) return undefined; let data = JSON.parse(localStorage[key]); if(data.saved) return data.saved; else return undefined; } } class Panels{ constructor(parent){ this.parent = parent; this.panels = {}; this.listen(); } listen(){ window.addEventListener('keydown', (e) => { if(e.key !== 'Escape') return; if(['input', 'textarea'].includes(document.activeElement.localName)) return; Object.keys(this.panels).forEach(key => this.hide(key)); }, true); } add(name, panel){ this.panels[name] = panel; } toggle(name){ let panel = this.panels[name]; if(panel.isConnected === false || panel.classList.contains('hidden')) this.show(name); else this.hide(name); } show(name){ let panel = this.panels[name]; if(panel.isConnected) return; panel.classList.add('hidden'); this.parent.appendChild(panel); this.parent.dataset.panels = parseInt(this.parent.dataset.panels) + 1; animate(() => panel.classList.remove('hidden')); } hide(name){ let panel = this.panels[name]; if(panel.classList.contains('hidden')) return; panel.classList.add('hidden'); panel.addEventListener('transitionend', (e) => { this.parent.removeChild(panel); this.parent.dataset.panels = parseInt(this.parent.dataset.panels) - 1; }, {once: true}); } } const $ = function(s, f){ let target = document.querySelector(s); if(target === null) return null; return f ? f(target) : target; }; const $$ = function(s, f){ let targets = document.querySelectorAll(s); return f ? Array.from(targets).map(t => f(t)) : targets; }; const animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))}; const createElement = function(html = '<span></span>'){ let outer = document.createElement('div'); outer.innerHTML = html; return outer.firstElementChild; }; const observe = function(element, callback, options = {childList: true, attributes: false, characterData: false, subtree: false}){ let observer = new MutationObserver(callback.bind(element)); observer.observe(element, options); return observer; }; const normalize = function(string){ return string.replace(/[!-~]/g, function(s){ return String.fromCharCode(s.charCodeAt(0) - 0xFEE0); }).replace(normalize.RE, function(s){ return normalize.KANA[s]; }).replace(/ /g, ' ').replace(/~/g, '〜'); }; normalize.KANA = { ガ:'ガ', ギ:'ギ', グ:'グ', ゲ:'ゲ', ゴ: 'ゴ', ザ:'ザ', ジ:'ジ', ズ:'ズ', ゼ:'ゼ', ゾ: 'ゾ', ダ:'ダ', ヂ:'ヂ', ヅ:'ヅ', デ:'デ', ド: 'ド', バ:'バ', ビ:'ビ', ブ:'ブ', ベ:'ベ', ボ: 'ボ', パ:'パ', ピ:'ピ', プ:'プ', ペ:'ペ', ポ: 'ポ', ヷ:'ヷ', ヺ:'ヺ', ヴ:'ヴ', ア:'ア', イ:'イ', ウ:'ウ', エ:'エ', オ:'オ', カ:'カ', キ:'キ', ク:'ク', ケ:'ケ', コ:'コ', サ:'サ', シ:'シ', ス:'ス', セ:'セ', ソ:'ソ', タ:'タ', チ:'チ', ツ:'ツ', テ:'テ', ト:'ト', ナ:'ナ', ニ:'ニ', ヌ:'ヌ', ネ:'ネ', ノ:'ノ', ハ:'ハ', ヒ:'ヒ', フ:'フ', ヘ:'ヘ', ホ:'ホ', マ:'マ', ミ:'ミ', ム:'ム', メ:'メ', モ:'モ', ヤ:'ヤ', ユ:'ユ', ヨ:'ヨ', ラ:'ラ', リ:'リ', ル:'ル', レ:'レ', ロ:'ロ', ワ:'ワ', ヲ:'ヲ', ン:'ン', ァ:'ァ', ィ:'ィ', ゥ:'ゥ', ェ:'ェ', ォ:'ォ', ッ:'ッ', ャ:'ャ', ュ:'ュ', ョ:'ョ', "。":'。', "、":'、', "ー":'ー', "「":'「', "」":'」', "・":'・', }; normalize.RE = new RegExp('(' + Object.keys(normalize.KANA).join('|') + ')', 'g'); const log = function(){ if(typeof DEBUG === 'undefined') return; let l = log.last = log.now || new Date(), n = log.now = new Date(); let error = new Error(), line = log.format.getLine(error), callers = log.format.getCallers(error); //console.log(error.stack); console.log( SCRIPTID + ':', /* 00:00:00.000 */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3), /* +0.000s */ '+' + ((n-l)/1000).toFixed(3) + 's', /* :00 */ ':' + line, /* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') + /* caller */ (callers[1] || '') + '()', ...arguments ); }; log.formats = [{ name: 'Firefox Scratchpad', detector: /MARKER@Scratchpad/, getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1], getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm), }, { name: 'Firefox Console', detector: /MARKER@debugger/, getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1], getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm), }, { name: 'Firefox Greasemonkey 3', detector: /\/gm_scripts\//, getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1], getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm), }, { name: 'Firefox Greasemonkey 4+', detector: /MARKER@user-script:/, getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 500, getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm), }, { name: 'Firefox Tampermonkey', detector: /MARKER@moz-extension:/, getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 2, getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm), }, { name: 'Chrome Console', detector: /at MARKER \(<anonymous>/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1], getCallers: (e) => e.stack.match(/[^ ]+(?= \(<anonymous>)/gm), }, { name: 'Chrome Tampermonkey', detector: /at MARKER \(chrome-extension:.*?\/userscript.html\?name=/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1] - 1, getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm), }, { name: 'Chrome Extension', detector: /at MARKER \(chrome-extension:/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1], getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm), }, { name: 'Edge Console', detector: /at MARKER \(eval/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1], getCallers: (e) => e.stack.match(/[^ ]+(?= \(eval)/gm), }, { name: 'Edge Tampermonkey', detector: /at MARKER \(Function/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 4, getCallers: (e) => e.stack.match(/[^ ]+(?= \(Function)/gm), }, { name: 'Safari', detector: /^MARKER$/m, getLine: (e) => 0,/*e.lineが用意されているが最終呼び出し位置のみ*/ getCallers: (e) => e.stack.split('\n'), }, { name: 'Default', detector: /./, getLine: (e) => 0, getCallers: (e) => [], }]; log.format = log.formats.find(function MARKER(f){ if(!f.detector.test(new Error().stack)) return false; //console.log('////', f.name, 'wants', 0/*line*/, '\n' + new Error().stack); return true; }); const warn = function(){ if(!DEBUG) return; let body = Array.from(arguments).join(' '); if(warn.notifications[body]) return; Notification.requestPermission(); warn.notifications[body] = new Notification(SCRIPTNAME, {body: body}); warn.notifications[body].addEventListener('click', function(e){ Object.values(warn.notifications).forEach(n => n.close()); warn.notifications = {}; }); log(body); }; warn.notifications = {}; const time = function(label){ if(!DEBUG) return; const BAR = '|', TOTAL = 100; switch(true){ case(label === undefined):/* time() to output total */ let total = 0; Object.keys(time.records).forEach((label) => total += time.records[label].total); Object.keys(time.records).forEach((label) => { console.log( BAR.repeat((time.records[label].total / total) * TOTAL), label + ':', (time.records[label].total).toFixed(3) + 'ms', '(' + time.records[label].count + ')', ); }); time.records = {}; break; case(!time.records[label]):/* time('label') to create and start the record */ time.records[label] = {count: 0, from: performance.now(), total: 0}; break; case(time.records[label].from === null):/* time('label') to re-start the lap */ time.records[label].from = performance.now(); break; case(0 < time.records[label].from):/* time('label') to add lap time to the record */ time.records[label].total += performance.now() - time.records[label].from; time.records[label].from = null; time.records[label].count += 1; break; } }; time.records = {}; core.initialize(); if(window === top && console.timeEnd) console.timeEnd(SCRIPTID); })();