-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathYouTubeCommentNotifier.user.js
More file actions
580 lines (498 loc) · 22.4 KB
/
YouTubeCommentNotifier.user.js
File metadata and controls
580 lines (498 loc) · 22.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
// ==UserScript==
// @name LiveStreamChatNotifier.user.js
// @description YouTubeのライブチャットのストリームで特定のメッセージを通知してくれるやつ
// @namespace https://github.com/syusui-s/LiveStreamChatNotifier.user.js
// @version 1.2.13
// @match https://www.youtube.com/live_chat*
// @match https://gaming.youtube.com/live_chat*
// @run-at document-end
// @downloadURL https://syusui-s.github.io/LiveStreamChatNotifier.user.js/YouTubeCommentNotifier.user.js
// @updateURL https://syusui-s.github.io/LiveStreamChatNotifier.user.js/YouTubeCommentNotifier.user.js
// @grant GM.notification
// ==/UserScript==
const baseUrl = 'https://syusui-s.github.io/LiveStreamChatNotifier.user.js';
const workUrls = [
'https://www.youtube.com/live_chat',
'https://gaming.youtube.com/live_chat',
];
/**
* 指定のミリ秒 ms だけ、何もしないで待機する
*
* @param {number} ms 待機ミリ秒
*/
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
/**
* 処理を再試行する async function
*
* @param {number} count 再試行の最大回数
* @param {number} interval 次の再試行までの間隔をミリ秒で指定する
* @param {function} fn 処理
* @param {array} args 処理への引数
*/
const retry = async (count, interval, fn, ...args) => {
for (let i = 0; i < count; ++i) {
const result = await fn(...args);
if (result)
return result;
await sleep(interval);
}
};
const notifySound = {
audio: new Audio('data:audio/mp3;base64,//NExAANmAJeX0EQAPYpJqm5HQa3Pg/Eb4IRAcdLvtLggcdKOgg4EAQdKO/iM//KBj//ygY/lz/1AgA/xAGP//y/g+oKv/vry70DwEDwHgPobBpRKnCxUZEJ4FAKALyr//NExBwZJAqNlYdQAGrIBXBvzDWICxaeLjx+XAFgCxvt6FuBUBsb85/IzH9hEf////+ehL0M6kQxMTYt7AJj4kFsfl2/////+ur2/////+lf//6DwkqngR65QuV1JP////NExAoUFAqoAYJoAF68ZgEYVFsT8C/P5cE5E7PIooqSS/Wv9Zv+f9Sfb//+//yqFn/8Ros/8eoWAwf71of/X22/+Z//+xwpnv/6veYkD/9X1ZOqG+/24SlADg3qxwQh//NExAwUCm7qX8IoAs7ojDRnFAmB0dhUSESOLf/0EgGD4cEwOHw4OUw7/kOQ5isYxn//////////0MZhIPDRQeOFxAwoD44EAyIF7f////qIVQZYl9tgEBP+z/ZYFQHD//NExA4UeRa2+DAfINAeoDu7u7z3t+dlq3z9z/Fm1W7N0+g6hii/EIQkesQwXAegljCrw4b//MAqIQIPAgYWDDv//1TATATyQdCRkyZGp//auu1u2ALgAEhk6P9hwhbC//NExA8T8XruXhgTJjzz+SltQRGm/Tp//1v/tCgA0DoUFArCgfPhRkLisSLo0eqCgUJiiLahl3////////h1rAqAgkHjJkBhEiAwYJhiGt21kkACCD9jVlWEENUPL6Xy//NExBIWGbayXBnTZBlS8jJWvrsxxjKPnka///xDAcHiELAkCQJAkCRoQgiuCIeHgyNDYCAHEYODJMJAvIktgFwsA3hggQB9584fGyP/9UreueK1AFAAFikA/7D///5c//NExAwOqp72RggNUpF6O////+z+vPp8lnwONJESRJEcMJghaQQIRCzFE1mIGEHPsCAgfBAEAQBMH/y6v+32GAAoAFximAUJBM4sdymKowTYjUVSTt+dSJKv//////1///NExCQPO67+XigHzv9WJmUmNWVqjKhqFDVHEDiRQoSwzo4ABge3/fb/7XWi0Z2TYOysk+7i0aLrZMpscy7hnslQ0OwPMl+le/tSPNZeH5EzMJobPB1YGKhos6Vdfu6P//NExDoSaU7uXgmGEqlf/6ImMpc6pfFf/mqi8CI4v2QIkG4EBH0JPEcIe0JZYXiUlW0anDCzD4q8RyMMmK7vib5k+T/U1KQnwh//////6//9ssqKxikr//f////9WTyl//NExEMSC6KA8EBE/WDHEkDN1QkwBUFpH4MYnqgOIlJ6oxCla2OT2FDne43FhXiwiYSr/8mmpGszWGsNbDWGTBTJqTezUmsMBDAgaGjwq/////7No///7tX32gUyPVTP//NExE0SeXY0AHhG6Ov/+Kf7etv8WFf4q3i7MW4qKN/FhdmsW1inFhX9YqLdbKhZmsUb+LC7O3FRTi1MQU1FMy4xMDBVVVVVVVVVVVVVVVVVVVVVVVVVVVVVTEFNRTMu//NExFYMOAGgDAhEADEwMFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NExHgAAANIAAAAAFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NExKwAAANIAAAAAFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV'),
play() {
this.audio.play();
},
};
/**
* 内部実装としてMapを使うSet
*/
class MapSet/*::<T>*/ {
/*::
map: Map<T, boolean>
*/
constructor(items/*: Iterable<T>*/) {
const map = new Map();
Array.from(items).forEach(item => map.set(item, true));
this.map = map;
}
has(item/*: T */) {
return this.map.has(item);
}
add(item) {
this.map.set(item, true);
}
delete(item) {
this.map.delete(item);
}
[Symbol.iterator]() {
return (function *(map) {
for (const [item] of map)
yield item;
})(this.map);
}
}
class TimeoutError extends Error { }
class RemoteStorage {
static async create(storageUrl, timeout) {
const storage = new RemoteStorage(storageUrl);
await storage.appendToWindow(timeout);
return storage;
}
// https://syusui-s.github.io/LiveStreamChatNotifier.user.js/settings/storage.html
/*::
iframe: HTMLIFrameElement
storageUrl: URL
*/
constructor(storageUrl/*: URL */) {
const iframe = document.createElement('iframe');
iframe.src = storageUrl.toString();
iframe.style.display = 'none';
Object.assign(this, {
iframe,
storageUrl,
});
}
async appendToWindow(timeout = 5000) {
return new Promise((resolve, reject) => {
this.iframe.addEventListener('load', () => resolve());
window.document.body.appendChild(this.iframe);
setTimeout(() => reject(new TimeoutError()), timeout);
});
}
async request(message/* : { type: string, payload: {} } */, timeout = 5000) {
const requestId = Math.random();
const messageStr = JSON.stringify({ ...message, requestId });
this.iframe.contentWindow.postMessage(messageStr, this.storageUrl.origin);
return this.listen(requestId, timeout);
}
async listen(expectedRequestId, timeout) {
return new Promise((resolve, reject) => {
const listener = event => {
if (event.origin !== this.storageUrl.origin)
return;
const data = JSON.parse(event.data);
if (data.requestId !== expectedRequestId)
return;
window.removeEventListener('message', listener);
resolve(data);
};
setTimeout(() => {
reject(new TimeoutError());
window.removeEventListener('message', listener);
}, timeout);
window.addEventListener('message', listener, false);
});
}
async getItem(key) {
const { type, payload } = await this.request({
type: 'GET_ITEM',
payload: { key },
});
switch (type) {
case 'OK':
return payload.value;
default:
throw new TypeError(`Unknown type '${type}'`);
}
}
async setItem(key, value) {
const { type } = await this.request({
type: 'SET_ITEM',
payload: { key, value },
});
switch (type) {
case 'OK':
return;
default:
throw new TypeError(`Unknown type '${type}'`);
}
}
}
class YouTubeSettings {
static fromObject(obj) {
const { channelNames } = obj;
return new this(channelNames);
}
static default() {
return new this([
'A.I.Channel', 'A.I.Games', 'Kaguya Luna Official', 'Mirai Akari Project', 'Siro Channel', 'ひなたチャンネル (Hinata Channel)', 'けもみみおーこく国営放送', '萌実 & ヨメミ - Eilene', 'SoraCh. ときのそらチャンネル', '鳩羽つぐ', 'バーチャルおばあちゃんねる', 'Aoi ch.', 'ゲーム部プロジェクト', 'のらきゃっとチャンネル', '【世界初?!】男性バーチャルYouTuber ばあちゃる', '薬袋カルテ - バーチャル診療所', 'Azuma Lim Channel -アズマ リム-',
'Mari Channel', 'チャンネルコウノスケ', 'Hacka Channel ハッカドール', 'ヒメ チャンネル', 'YUA/藤崎由愛', 'ピーナッツくん!オシャレになりたい!',
'Gengen Channel', '乾ちゃんねる', '甲賀流忍者!ぽんぽこ', 'さなちゃんねる', 'Laki Station ラキステーション', 'ベイレーンチャンネル (Beilene Channel)',
'Uka\'s room', 'ウェザーロイド Airi(ポン子)', 'Zombi-Ko Channel', 'もちひよこ', 'ケリン', 'あっくん大魔王', 'Roboco Ch. - ロボ子', 'おめがシスターズ [Ω Sister]', 'さはな【VTuber】', 'バーチャルYouTuber万楽えね', 'MeguRoom', 'ニーツちゃんねる', '電脳少女シロGames', '滓残', 'バーチャルゴリラ', 'DeepWebUnderground', 'Hibiki Ao', '最果ての魔王ディープブリザード', 'みゅ みゅ', '岩本町芸能社YouTube', '春日部つくし', '北上双葉', '霊電カスカ', '夜桜たま', 'ぱかチューブっ!', '日雇礼子のドヤ街暮らしチャンネル', '海月ねうmituki neu', 'Tsunohane Akagi Vtube', 'もこめめ*channel', '馬越健太郎チャンネル', 'カルロ ピノ', '金剛いろは', '小林幸子のさっちゃんねる', 'ちえり花京院', 'Kanata Hikari / LYTO【バーチャルYoutuber】', 'Hoonie friends', '織田信姫', 'ミディ / 作曲バーチャルyoutuber', 'さょちゃんのVR図書室', '虚拟DD', '木曽あずき', 'バーチャル園児-めいちゃんねる',
'猫乃木もち', 'いるはーと', '地獄ちゃんねる', 'ぜったい天使くるみちゃん', '異世界転生系魔王ヘルネス', 'ねむちゃんねる【バーチャル美少女YouTuber】', '八重沢なとり', 'ネコケン Nekoken世紀末系猫耳幼女バーチャルYouTuber', '/ ODDAIオッドアイ', 'ユキミお姉ちゃんねる', '魔法少女ちあちあちゃんねる', '牛巻 りこ', 'Channelパゲ美のバーチャルオカマ', '珠根うたChannel', 'モスコミュール放送局', 'ico通夜の黄泉巡りch', 'poemcore tokyo', 'DOLL GAL millna', 'クゥChannel', 'あさひちゃん寝る【バーチャルYouTuber】', '神楽すず', 'ヤマト イオリ', 'たかじんちゃんねる【バーチャルyoutuber】', 'ナイセンチャンネル naisen channel', '天神 子兎音 Tenjin Kotone', 'スパイト-spite-【公式】', 'メイカちゃんねる', '〜旅するバーチャルyoutuber〜動く城のフィオ', '食虫植物TV -Carnivorous Plants videos-', 'イヌージョンCHANNEL', 'Arcadia L.E. Projectバリトンエルフ', 'かまってちゃんねる', 'あいえるちゃんねる/株式会社インフィニットループ', 'リクビッツ / バーチャルYouTuber', '/食虫植物系VTuberネアちゃんねる', 'シテイルチャンネル', 'Reratan', 'デラとハドウ Channel', 'Channelれらたん', '世界クルミ/バーチャルYouTuber', '白二郎/VRアライグマ', 'Mel Channel 夜空メルチャンネル', 'ファイ博士φ電脳サイエンティスト', '真空管ドールズ公式',
'2.5次元バーチャルキャスター「獣音ロウ&式大元」チャンネル', 'ぼっちぼろまる', '淫獣帝国', 'Kite Channel', 'すくろーるちゃんねる!!! / 巣黒るい', 'Kimino Yumeka Official', '新川良', '天野声太郎', 'Sophiaちゃんねる', '人工知能AI ユニ', '白鳥天羽【バーチャル百合お嬢様】', 'RAY WAKANA', 'ありしあちゃんねる', 'MIALチャンネル', 'クーテトラチャンネル', 'スズキセシル', 'バーチャルおじいちゃん / G3Games', 'ドットチャンネル./DotChannel.', '星菜日向夏のゼロ時間目', '魔王の息子わんわん', 'そらのももか', 'コハクのおうち', 'バーチャルYouTuber蟹', 'くのいち子バーチャルユーチューバー', '姫宮縷愛', '魔界四天王ださお', 'バーチャル美少女 ハラムちゃんねる', '来栖エマema Ch.',
// ぱりぷろ
'ユメノシオリ',
'ユメノシオリさぶちゃんねる',
'神楽めあ / KaguraMea',
'千草はな / Chigusa Hana',
'森永みう/Morinaga Miu',
// すとらす
'高槻律 / Takatsuki ritsu',
'花園セレナ',
// にじさんじ
// 一期生出身
'月ノ美兎',
'勇気ちひろ',
'エルフのえる / にじさんじ所属',
'樋口楓【にじさんじ所属】',
'Shizuka Rin Official',
'渋谷ハジメのはじめ支部',
'アキくんちゃんネル',
'鈴谷アキの陽だまりの庭',
'《にじさんじ所属の女神》モイラ',
// COO+にじさんじ公式
'いわながちゃん',
'にじさんじ',
// 二期生出身
'鈴鹿詩子 Utako Suzuka',
'宇志海いちご',
'家長むぎ【にじさんじ所属】',
'Yuhi Riri Official',
'♥️♠️物述有栖♦️♣️',
'文野環【にじさんじの野良猫】ふみのたまき',
'伏見ガク【にじさんじ所属】',
'Gilzaren III Season 2',
'剣持刀也',
'森中花咲',
// ゲーマーズ出身
'Kanae Channel',
'Akabane Channel',
'笹木咲 / Sasaki Saku',
'闇夜乃モルル / Moruru Yamiyono',
'本間ひまわり - Himawari Honma -',
'魔界ノりりむ',
'Kuzuha Channel',
'雪汝*setsuna channel',
'椎名唯華',
// SEEDs一期生出身
'ドーラ',
'海夜叉神/黄泉波咲夜【にじさんじ】',
'名伽尾アズマ☀️',
'《IzumoKasumi》Project channel【にじさんじ】',
'轟京子/kyoko todoroki【にじさんじ】',
'シスター・クレア -SisterClaire-',
'花畑チャイカ',
'社築',
'安土桃',
'D.E.放送局【鈴木勝/にじさんじ】',
'Re‡D.E.放送局【鈴木勝/にじさんじ】',
'緑仙channel',
'みどりのさぶちゃんねる',
'卯月コウ',
'八朔ゆず【にじさんじ】',
// SEEDs二期生出身
'【にじさんじ】神田笑一',
'鳴門こがね',
'飛鳥ひな【にじさんじ所属】',
'春崎エアル',
'雨森小夜',
'鷹宮リオン',
'舞元啓介',
'竜胆 尊 / Rindou Mikoto',
'でびちゃんねる',
'でびでび・でびる',
'桜凛月',
'町田ちま【にじさんじ】',
'月見しずく',
'ジョー・力一 Joe Rikiichi',
'遠北千南 / Achikita Chinami 【にじさんじ】',
'成瀬 鳴',
'ベルモンド・バンデラス',
'矢車りね - Rine Yaguruma -',
'夢追翔のJUKE BOX',
'黒井しば【にじさんじの犬】',
// 統合以降
'童田明治-わらべだめいじー-',
'Kudou_chitose / 久遠千歳',
'【3年0組】郡道美玲の教室',
'夢月ロア🌖Yuzuki Roa',
'小野町春香-OnomachiHaruka-にじさんじ',
'語部紡',
'瀬戸 美夜子 - Miyako Seto',
'御伽原 江良 / Otogibara Era【にじさんじ】',
'戌亥とこ-Inui Toko-【にじさんじ】',
'アンジュ・カトリーナ - Ange Katrina -',
'リゼ・ヘルエスタ -Lize Helesta-',
'三枝明那 / Akina Saegusa',
'愛園 愛美/Aizono Manami',
'鈴原るる【にじさんじ所属】',
'雪城眞尋/Yukishiro Mahiro【にじさんじ所属】',
'エクス・アルビオ -Ex Albio-',
'レヴィ・エリファ-Levi Elipha-',
'葉山舞鈴 / Hayama Marin【にじさんじ所属】',
'ニュイ・ソシエール //[Nui Sociere]',
'葉加瀬 冬雪 / Hakase Fuyuki',
'加賀美 ハヤト/Hayato Kagami',
'夜見れな/yorumi rena【にじさんじ所属】',
'黛 灰 / Kai Mayuzumi【にじさんじ】',
'アルス・アルマル -ars almal- 【にじさんじ】',
'相羽ういは〖Aiba Uiha〗にじさんじ所属',
'天宮 こころ / Kokoro Amamiya 【にじさんじ所属】',
'エリー・コニファー / Eli Conifer【にじさんじ】',
'ラトナ・プティ -Ratna Petit -にじさんじ所属',
'早瀬 走 / Hayase Sou【にじさんじ所属】',
'健屋花那【にじさんじ】KanaSukoya',
'シェリン・バーガンディ -Shellin Burgundy- 【にじさんじ】',
'フミ/にじさんじ',
'星川サラ / Sara Hoshikawa',
'山神 カルタ / Karuta Yamagami',
// ホロライブ
'フブキCh。白上フブキ', 'Aki Channel アキ・ローゼンタール', 'Kurisu Channel 人見クリス', 'Haato Channel 赤井はあと', 'Matsuri Channel 夏色まつり',
// あにまーれ
'Ichika Channel / 宗谷 いちか 【あにまーれ】', 'Ran Channel / 日ノ隈らん 【あにまーれ】', 'Hinako Channel / 宇森ひなこ 【あにまーれ】', 'Kuromu Channel / 稲荷くろむ 【あにまーれ】', 'Haneru Channel / 因幡はねる 【あにまーれ】', 'AniMare Official / あにまーれ公式',
]);
}
/*::
channelNames: MapSet<string>
*/
constructor(channelNames) {
Object.assign(this, {
channelNames: new MapSet(channelNames),
});
}
toObject() {
const { channelNames } = this;
return {
channelNames: [...channelNames]
};
}
}
class YouTubeSettingsRepository {
static get keyName() {
return 'YouTubeSettings';
}
/*::
store: Storage | RemoteStorage
*/
constructor(store) {
Object.assign(this, { store });
}
async getSettings() {
const settingsJson = await this.store.getItem(this.constructor.keyName);
if (! settingsJson) return;
const settingsObj = JSON.parse(settingsJson);
if (! settingsObj) return;
return YouTubeSettings.fromObject(settingsObj);
}
async saveSettings(settings) {
const settingsJson = JSON.stringify(settings.toObject());
return this.store.setItem(this.constructor.keyName, settingsJson);
}
}
/**
* ライブストリームに流れるメッセージ
*/
class Message {
/*::
author: string
iconUrl: string
badgeType: ?string
body: string
*/
/**
* @param {string} author 投稿者名
* @param {string} iconUrl 投稿者のアイコン
* @param {?string} badgeType 投稿者のバッジ
* @param {string} body メッセージの本体
*/
constructor(author, iconUrl, badgeType, body) {
Object.assign(this, { author, iconUrl, badgeType, body, });
}
/**
* メッセージの投稿者名が引数のMapSetに含まれているならtrueを返す
*
* @param {MapSet} names 投稿者名のMapSet
* @return {boolean} 含まれているかどうか
*/
hasNameSome(names) {
return names.has(this.author);
}
/**
* モデレータならばtrueを返す
*
* モデレータについてはこちら: https://support.google.com/youtube/answer/7023301?hl=ja
*
* 「にじさんじ」の放送では、他の配信者がモデレータになっていることが多い。
*/
isModerator() {
return this.badgeType === 'moderator';
}
/**
* ライブの配信者であればtrueを返す
*/
isOwner() {
return this.badgeType === 'owner';
}
}
class NotifierGM {
notify(message) {
// HACK スパチャなどで本文が空の場合に備えて、各テキストに空白文字を追加している
// GM.notification は、textが空だと通知してくれないんですよね
GM.notification({
title: message.author,
text: `${message.body} `,
image: message.iconUrl,
});
}
async requestPermission() {
return true;
}
supported() {
return 'GM' in window && 'notification' in window.GM;
}
}
class NotifierNotificationAPI {
notify(message) {
new Notification(message.author, {
body: message.body,
icon: message.iconUrl,
});
}
async requestPermission() {
const result = await Notification.requestPermission();
return result === 'granted';
}
supported() {
return 'Notification' in window;
}
}
/**
* 通知に関する処理を置いておく Domain Service
*/
class NotificationService {
/*::
notifier: NotifierGM | NotifierNotificationAPI
notifySound: { play: function }
authorNames: MapSet<string>
*/
/**
* @param {Notifier} notifier 通知を提供するサービス
* @param {object} notifySound 通知音を鳴らしてくれるような仕組みを持つオブジェクト
* @param {MapSet} authorNames 通知したい投稿者名の配列
*/
constructor(notifier, notifySound, authorNames) {
Object.assign(this, { notifier, notifySound, authorNames });
}
/**
* 指定のメッセージを条件に従って通知します
*
* @param {Message} message
*/
notify(message) {
if (message.isModerator() || message.isOwner() || message.hasNameSome(this.authorNames)) {
this.notifier.notify(message);
this.notifySound.play();
}
}
/**
* 権限を要求する
*/
async requestPermission() {
return this.notifier.requestPermission();
}
}
async function main() {
const storageUrl = new URL(`${baseUrl}/settings/storage.html`);
const remoteStorage = await RemoteStorage.create(storageUrl);
const repository = new YouTubeSettingsRepository(remoteStorage);
const settings = await repository.getSettings() || YouTubeSettings.default();
const notifier = [
new NotifierGM(),
new NotifierNotificationAPI(),
].find(notifier => notifier.supported());
if (! notifier) {
window.alert('ブラウザが通知機能に対応していません。この拡張機能を利用できません。');
return;
}
const notificationService = new NotificationService(notifier, notifySound, settings.channelNames);
if (! await notificationService.requestPermission()) {
window.alert('Notificatonの権限がありません');
return;
}
const RETRY = 30; // 回
const INTERVAL = 500; // ミリ秒
const chatItemList = await retry(RETRY, INTERVAL, async () =>
document.querySelector('#items.yt-live-chat-item-list-renderer')
);
if (! chatItemList)
return;
const isEmoji = node =>
node instanceof Image && node.classList.contains('emoji');
const bodyElemToText = bodyElem =>
Array.from(bodyElem.childNodes)
.map(e => isEmoji(e) ? e.alt : e.textContent )
.join('');
const toMessage = chatItem => {
const nameElem = chatItem.querySelector('#author-name');
const iconElem = chatItem.querySelector('yt-img-shadow > img#img');
const bodyElem = chatItem.querySelector('#message');
if (nameElem && iconElem) {
const name = nameElem.firstChild.textContent;
const iconUrl = iconElem.src.replace(/[^/]*\/photo.jpg$/, '');
const body = bodyElemToText(bodyElem);
const badgeType = nameElem.getAttribute('type');
return new Message(name, iconUrl, badgeType, body);
}
};
const observer = records => {
records.forEach(record => {
switch (record.type) {
case 'childList':
record.addedNodes.forEach(chatItem => {
const messageOpt = toMessage(chatItem);
if (messageOpt)
notificationService.notify(messageOpt);
});
break;
}
});
};
const m = new MutationObserver(observer);
m.observe(chatItemList, { childList: true });
return m;
}
const isWorkUrl = url =>
workUrls.some(workUrl => url.startsWith(workUrl));
if (isWorkUrl(window.location.href))
main();