// ==UserScript==
// @name AbemaTV Timetable Viewer
// @namespace knoa.jp
// @description AbemaTV に見やすくて使いやすい番組表と、気軽に登録できる通知機能を提供します。
// @include https://abema.tv/*
// @version 0.9.2
// @grant none
// @downloadURL none
// ==/UserScript==
// console.log('AbemaTV? => hireMe()');
(function(){
const SCRIPTNAME = 'TimetableViewer';
const DEBUG = false;/*
[update]
配色を再調整しました。ほか、軽微な修正。
[to do]
自動復帰しないことがある
[to research]
現在時刻に戻るスクロールだけやや遅い
マウスホバー判定の1pxギャップを上手になくしたい
0:00時点でまだその日の番組情報が空っぽの枠があることが...
[possible]
単独起動で通知自動切り替え時の裏番一覧イベント流用
チャンネル切り替えでチャンネルロゴアニメーション
あらかじめ一覧を用意しておいて切り替え時のみ2秒ほど表示させるか
スマホUI/アプリ提案?(番組表・通知)
Edge: element.animate ポリフィル
windowイベントリスナの統一化,(events)
*/
if(window === top && console.time) console.time(SCRIPTNAME);
const UPDATECHANNELS = false;/*デバッグ用*/
const CONFIGS = {
/* 番組表パネル */
transparency: {TYPE: 'int', DEFAULT: 25},/*透明度(%)*/
height: {TYPE: 'int', DEFAULT: 50},/*番組表の高さ(%)(文字サイズ連動)*/
span: {TYPE: 'int', DEFAULT: 4},/*番組表の時間幅(時間)*/
replace: {TYPE: 'bool', DEFAULT: 1 },/*アベマ公式の番組表を置き換える*/
/* 通知(abema.tvを開いているときのみ) */
n_before: {TYPE: 'int', DEFAULT: 5},/*番組開始何秒前に通知するか(秒)*/
n_change: {TYPE: 'bool', DEFAULT: 1 },/*自動でチャンネルも切り替える*/
n_overlap: {TYPE: 'bool', DEFAULT: 1 },/* 時間帯が重なっている時は通知のみ*/
n_sync: {TYPE: 'bool', DEFAULT: 1 },/*アベマ公式の通知と共有する*/
/* 表示チャンネル */
c_visibles: {TYPE: 'object', DEFAULT: {}},/*(チャンネル名)*/
};
const PIXELRATIO = window.devicePixelRatio;/*Retina比*/
const MINUTE = 60;/*分(s)*/
const HOUR = 60*MINUTE;/*時間(s)*/
const DAY = 24*HOUR;/*日(s)*/
const JST = 9*HOUR;/*JST時差(s)*/
const JDAYS = ['日', '月', '火', '水', '木', '金', '土'];/*曜日*/
const EDAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];/*曜日(class)*/
const TERM = 7 + 1;/*番組スケジュールの取得期間(日)*/
const TERMLABEL = '1週間';/*TERMのユーザー向け表現*/
const CACHEEXPIRE = DAY*1000;/*番組スケジュールのキャッシュ期間(ms)*/
const BOUNCINGPIXEL = 1;/*バウンシングエフェクト用ピクセル*/
const TIMES = [0,3,6,9,12,15,18,21];/*番組表のスクロール位置(時)*/
const NAMEWIDTH = 7.5;/*番組表のチャンネル名幅(vw)*/
const MAXRESULTS = 100;/*番組取得の最大数*/
const NOTIFICATIONREMAINS = 5;/*番組開始後も通知を残しておく時間(s)*/
const NOTIFICATIONAFTER = DAY;/*番組終了後にアベマを開いても通知する期間(s)*/
const ABEMATIMETABLEDURATION = 500;/*アベマ公式番組表を置き換えた際の遷移アニメーション時間(ms)*/
const PANELS = ['timetablePanel', 'configPanel'];/*パネルの表示順*/
const STALLEDLIMIT = 5;/*映像が停止してから自動リロードするまでの時間(s)*/
/* サイト定義 */
const APIS = {
CHANNELS: 'https://api.abema.io/v1/channels',/*全チャンネル取得API*/
SCHEDULE: 'https://api.abema.io/v1/media?dateFrom={dateFrom}&dateTo={dateTo}',/*番組予定取得API*/
RESERVATION: 'https://api.abema.io/v1/viewing/reservations/{type}/{id}',/*番組通知API*/
RESERVATIONS: 'https://api.abema.io/v1/viewing/reservations/slots?limit={limit}',/*番組通知取得API*/
FAVORITE: 'https://api.abema.io/v1/favorites/slots/{id}?userId={userId}',/*マイビデオAPI*/
FAVORITES: 'https://api.abema.io/v1/favorites/slots?limit={limit}',/*マイビデオ取得API*/
SLOT: 'https://api.abema.io/v1/viewing/reservations/slots/{id}',/*通知番組情報取得API*/
};
const THUMBIMG = 'https://hayabusa.io/abema/programs/{displayProgramId}/{name}.q{q}.w{w}.h{h}.x{x}.jpg';/*番組サムネイルパス*/
const NOCONTENTS = [/*コンテンツなし番組タイトル*/
'番組なし',/*存在しないけどNOCONTENTS[0]はスクリプト内で代替用のラベルとして使う*/
/^番組告知$/,
/^CM$/,
/^$/,/*空欄*/
/^CM 【[^】]+】$/,/*【煽り】付きCM(REPLACE後の文字列にマッチ)*/
];
const REPLACES = [/*番組タイトル置換*/
[/^(【.+?】)(.*)$/, '$2 $1'],/*【煽り】を最後に回す(間に HAIR SPACE を挟む)*/
[/^(\\.+?\/)(.*)$/, '$2 $1'],/*\煽り/を最後に回す(間に SPACE を挟む)*/
[/^([^/]+一挙)\/(.*)$/, '$2 /$1'],/*...一挙/を最後に回す(間に SPACE を挟む)*/
[/^(イブニング4|ナイトフォール7|デイリーナイト10)\/(.*)$/, '$2 /$1'],/*枠名/を最後に回す(間に SPACE を挟む)*/
[/^(Abemaビデオで大好評配信中!)(.*)$/, '$2 $1'],/*最後に回す(間に SPACE を挟む)*/
[/♯([0-9]+)/g, '#$1'],/*シャープをナンバーに統一*/
[/([^ ])((?:\(|\[|<)?#[0-9]+)/g, '$1 $2'],/*直前にスペースがないナンバリングを補完*/
[/([^ ])(\(|\[|<)/g, '$1 $2'],/*直前にスペースがないカッコ開始を補完*/
];
const NAMEFRAGS = {/*キャストとスタッフの名前の正規化用*/
NONAMES: new RegExp([
'^(?:-|ー|未定|なし|coming soon)$', /*なし*/
'^(?:【|-|ー).+(?:-|ー|】)$', /*【見出し】*/
'^(?:■|◆|●)', /*■見出し*/
':.+:', /*コロン複数の複数人ベタテキストは判定不能*/
].join('|')),
SKIPS: new RegExp([
'^(?:[^(:]+|[^:]+\\)[^:]*):',/*最初のコロンまでは役職名(カッコ内は無視)*/
'^【[^】]+】', /*太カッコは区切り*/
'^<[^>]+>', /*山カッコは区切り*/
'\\([^)]+\\)(?:・|、|\\/)?', /*(カッコ)内とそれに続く区切り文字は無視*/
'\\[[^\\]]+\\](?:・|、|\\/)?', /*[カッコ]内とそれに続く区切り文字は無視*/
'\\s*(?:その)?(?:ほか|他)(?:多数)?$', /*ほか*/
'\\s*(?:etc\.?|(?:and|&) more)$', /*ほか*/
].join('|')),
SEPARATORS: new RegExp([
'、', /*名前、名前*/
'\\/', /*名前/名前*/
].join('|')),
};
let retry = 10;/*必要な要素が見つからずあきらめるまでの試行回数*/
let site = {
targets: [
function screen(){let loading = $('img[src="/images/misc/feed_loading.gif"]'); return (loading) ? site.use(loading.parentNode.parentNode) : false;},
function channelButton(){let button = $('button[aria-label="放送中の裏番組"]'); return (button) ? site.use(button) : false;},
function channelUpButton(){let button = $('button[aria-label="前のチャンネルに切り替える"]'); return (button) ? site.use(button) : false;},
function channelDownButton(){let button = $('button[aria-label="次のチャンネルに切り替える"]'); return (button) ? site.use(button) : false;},
function channelPane(){let button = $('button[aria-label="放送中の裏番組"]'); return (button) ? site.use(button.parentNode.parentNode.nextElementSibling) : false;},
function closer(){let buttons = $$('[data-selector="screen"] > div > button'); Array.from(buttons).forEach((b) => site.use(b, 'closer')); return (buttons) ? true : false;},
function progressbar(){let progressbar = $('#main [role="progressbar"]'); return (progressbar) ? site.use(progressbar.parentNode) : false;},
],
get: {
onairs: function(channelPane){return channelPane.querySelectorAll('a[href^="/now-on-air/"]');},
thumbnail: function(a){return a.querySelector('a > div > div:nth-child(1) > div > div:nth-child(1) > img');},
nowonair: function(a){return a.querySelector('a > div > div:nth-child(2)');},
title: function(slot){return slot.querySelector('div:nth-child(1) > span > span:last-of-type');},
duration: function(slot){return slot.querySelector('div:nth-child(2) > span');},
token: function(){return localStorage.getItem('abm_token');},
userId: function(){return localStorage.getItem('abm_userId');},
closer: function(){
/* チャンネル切り替えごとに変わる */
let buttons = $$('[data-selector="closer"]');
for(let i = 0; buttons[i]; i++){
if(buttons[i].clientWidth) return buttons[i];
}
},
abemaTimetableButton: function(){
let a = $('header a[href="/timetable"]');
return (a) ? site.use(a, 'abemaTimetableButton') : null;
},
abemaTimetableSlotButton: function(channelId, programId){
/* アベマの仕様に依存しまくり */
let index = Array.from($$('div > a[href^="/timetable/channels/"]')).findIndex((a) => a.href.endsWith('/' + channelId));
if(index === -1) return log(`Not found: "${channelId}" anchor.`);
let buttons = $$(`div:nth-child(${index + 1}) > div > article > button`);/*index該当チャンネルに絞って効率化*/
if(buttons.length === 0) return log(`Not found: "${channelId}" buttons.`);
if(DAY/2 < MinuteStamp.past()) buttons = Array.from(buttons).reverse();/*正午を過ぎていたら逆順に探す*/
for(let i = 0, button; button = buttons[i]; i++){
let div = button.parentNode.parentNode;
if(Object.keys(div).some((key) => key.includes('reactInternalInstance') && (div[key].key === programId))) return button;
}
return log(`Not found: "${programId}" button.`);
},
abemaTimetableNowOnAirLink: function(channelId){
let a = $(`a[href="/now-on-air/${channelId}"]`);
return (a) ? a : log(`Not found: "${channelId}" link.`);
},
abemaNotifyButton: function(target){
switch(true){
case(target.classList.contains('notify')):
return false;
/* textContentでしか判定できない */
case(target.textContent === 'この番組の通知を受け取る'):/*放送視聴中のボタン*/
case(target.textContent === '通知を受け取る'):
case(target.textContent === '今回のみ通知を受け取る'):
case(target.textContent === '毎回通知を受け取る'):
case(target.textContent === '解除する'):/*マイビデオの可能性もあるが仕方ない*/
return true;
default:
return false;
}
},
abemaMyVideoButton: function(target){
switch(true){
case(target.classList.contains('myvideo')):
return false;
/* 番組表の埋め込みボタンのあやうい判定 */
case(target.attributes['role'] && target.attributes['role'].value === 'checkbox'):
/* textContentでしか判定できない */
case(target.textContent === 'マイビデオに追加'):
case(target.textContent === '解除する'):/*通知の可能性もあるが仕方ない*/
return true;
default:
return false;
}
},
subscriptionType: function(){
/* アベマの仕様に依存しまくり */
if(!window.dataLayer) return log('Not found: window.dataLayer');
for(let i = 0; window.dataLayer[i]; i++){
if(window.dataLayer[i].subscriptionType) return window.dataLayer[i].subscriptionType;
}
},
screenCommentScroller: function(){return html.classList.contains('ScreenCommentScroller')},
apis: {
channels: function(){return APIS.CHANNELS},
timetable: function(){
let toDigits = (date) => date.toLocaleDateString('ja-JP', {year: 'numeric', month: '2-digit', day: '2-digit'}).replace(/[^0-9]/g, '');
let from = new Date(), to = new Date(from.getTime() + TERM*DAY*1000);
return APIS.SCHEDULE.replace('{dateFrom}', toDigits(from)).replace('{dateTo}', toDigits(to));
},
reservation: function(id, type){
const types = {repeat: 'slotGroups', once: 'slots'};
return APIS.RESERVATION.replace('{type}', types[type]).replace('{id}', id);
},
reservations: function(){return APIS.RESERVATIONS.replace('{limit}', MAXRESULTS)},
favorite: function(id){return APIS.FAVORITE.replace('{id}', id).replace('{userId}', site.get.userId())},
favorites: function(){return APIS.FAVORITES.replace('{limit}', MAXRESULTS)},
slot: function(id){return APIS.SLOT.replace('{id}', id)},
},
},
use: function use(target = null, key = use.caller.name){
if(target) target.dataset.selector = key;
elements[key] = target;
return target;
},
/* [live(生), newcomer(新), first(初), last(終), bingeWatching(一挙), recommendation(注目), none(なし)] の順番 */
marks: ['live', 'newcomer', 'first', 'last', 'bingeWatching', 'recommendation', 'none'],
};
class Channel{
constructor(channel = {}){
Object.keys(channel).forEach((key) => {
switch(key){
case('programs'): return this.programs = channel.programs.map((program) => new Program(program));
default: return this[key] = channel[key];
}
});
}
fromChannelSlots(channel, slots){
this.id = channel.id;
this.name = channel.name.replace(/^Abema/, '').replace(/チャンネル$/, '');
this.fullName = channel.name;
this.order = channel.order;
this.programs = slots.map((slot) => new Program().fromSlot(slot, {id: this.id, name: this.fullName}));
/* 空き時間を埋める */
let now = MinuteStamp.now(), justToday = MinuteStamp.justToday(), createPadding = (id, startAt, endAt) => new Program({
id: id,
title: NOCONTENTS[0],
noContent: true,
channel: {id: this.id, name: this.fullName},
startAt: startAt,
endAt: endAt,
});
if(now < this.programs[0].startAt) this.programs.unshift(createPadding(channel.id + '-' + now, now, this.programs[0].startAt));
for(let i = 0; this.programs[i]; i++){
if(this.programs[i + 1] && this.programs[i].endAt !== this.programs[i + 1].startAt){
this.programs.splice(i + 1, 0, createPadding(channel.id + '-' + this.programs[i].endAt, this.programs[i].endAt, this.programs[i + 1].startAt));
}else if(!this.programs[i + 1] && this.programs[i].endAt < justToday + (TERM+1)*DAY){
this.programs.push(createPadding(channel.id + '-' + this.programs[i].endAt, this.programs[i].endAt, justToday + (TERM+1)*DAY));
break;/*抜けないと無限ループになる*/
}
}
return this;
}
}
class Program{
constructor(program = {}){
Object.keys(program).forEach((key) => {
this[key] = program[key];
});
}
fromSlot(slot, channel){
/* ID */
this.id = slot.id;
this.displayProgramId = slot.displayProgramId;
this.series = (slot.programs[0].series) ? slot.programs[0].series.id : slot.programs[0].seriesId;
//this.sequence = slot.programs[0].episode.sequence;/*次回*/
this.slotGroup = slot.slotGroup;/*{id, lastSlotId, fixed, name}*/
/* 概要 */
/* {live(生), newcomer(新), first(初), last(終), bingeWatching(一挙), recommendation(注目), drm(マークなし)} からマークなしを取り除く */
Object.keys(slot.mark).forEach((key) => {
if(core.html.marks[key] === undefined){
delete slot.mark[key];
if(DEBUG && key !== 'drm') log('Unknown mark:', key);
}
});
this.marks = slot.mark || {};
this.title = Program.modifyTitle(normalize(slot.title));
this.links = slot.links;/*[{title, type(2のみ), value(url)}]*/
//this.highlight = slot.highlight;/*短い*/
this.detailHighlight = slot.detailHighlight/*長い*/ || slot.highlight/*短い*/;
this.content = slot.content;/*詳細*/
this.noContent = this.hasNoContent(this.title);
this.channel = channel;/*{id, name}*/
/* サムネイル */
this.thumbImg = slot.programs[0].providedInfo.thumbImg;
this.sceneThumbImgs = slot.programs[0].providedInfo.sceneThumbImgs || [];
/* クレジット */
this.casts = (slot.programs[0].credit.casts || []).map(normalize);
this.crews = (slot.programs[0].credit.crews || []).map(normalize);
this.copyrights = slot.programs[0].credit.copyrights;
/* 時間 */
this.startAt = slot.startAt;
this.endAt = slot.endAt;
this.timeshiftEndAt = slot.timeshiftEndAt;
this.timeshiftFreeEndAt = slot.timeshiftFreeEndAt;
/* シェア */
//this.hashtag = slot.hashtag;
//this.sharedLink = slot.sharedLink;
return this;
}
static modifyTitle(title){
for(let i = 0, replace; replace = REPLACES[i]; i++){
title = title.replace(replace[0], replace[1]);
}
return title;
}
static appendMarks(title, marks){
const latters = ['last'];/*タイトルの後に付くマーク*/
if(marks) Object.keys(marks).forEach((mark) => {
if(!core.html.marks[mark]) return;/*htmlが用意されていない*/
if(latters.includes(mark)) return title.parentNode.appendChild(createElement(core.html.marks[mark]()));
return title.parentNode.insertBefore(createElement(core.html.marks[mark]()), title);
});
}
static getRepeatTitle(a, b){
let getCommon = (a, b) => {
for(let i = 0, parts = a.split(/(?=\s)/), common = ''; parts[i]; i++){
if(b.includes(parts[i].trim())) common += parts[i];
else if(common) return common;/*共通部分が途切れたら終了*/
}
return b;/*共通部分がなければ後続を優先する*/
}
return [getCommon(a, b), getCommon(b, a)].sort((a, b) => a.length - b.length)[0].trim();
}
static modifyDuration(duration){
return duration.replace(/[0-9]+月[0-9]+日\([月火水木金土日]\)/g, '').replace(/0([0-9]:[0-9]{2})/g, '$1');
}
static linkifyNames(node, click){
if(node.textContent.match(NAMEFRAGS.NONAMES) !== null) return;
for(let i = 0, n; n = node.childNodes[i]; i++){/*回しながらchildNodesは増えていく*/
if(n.data === '') continue;
let pos = n.data.search(NAMEFRAGS.SKIPS);
switch(true){
case(pos === -1):
if(split(n)) i++;/*セパレータの分を1つ飛ばす*/
append(n);
break;
case(pos === 0):
n.splitText(RegExp.lastMatch.length);
break;
case(0 < pos):
n.splitText(pos);/*nをpos直前で分割*/
if(split(n)) i++;/*セパレータの分を1つ飛ばす*/
append(n);
break;
}
}
function split(n){
let pos = n.data.search(NAMEFRAGS.SEPARATORS);
if(1 <= pos){
n.splitText(pos);
n.nextSibling.splitText(RegExp.lastMatch.length);
return true;
}
}
function append(n){
n.data = n.data.trim();
if(n.data === '') return;
let span = document.createElement('span');
span.className = 'name';
node.insertBefore(span, n.nextSibling);
span.appendChild(n);
span.addEventListener('click', click);
}
}
get group(){
return (this.slotGroup) ? this.slotGroup.id : undefined;
}
get repeat(){
return this.group;
}
get once(){
return this.id;
}
get duration(){
return this.endAt - this.startAt;
}
get dateString(){
let long = {month: 'short', day: 'numeric', weekday: 'short', hour: 'numeric', minute: '2-digit'}, short = {hour: 'numeric', minute: '2-digit'};
let start = new Date(this.startAt*1000), end = new Date(this.endAt*1000);
let startString = start.toLocaleString('ja-JP', long);
let endString = end.toLocaleString('ja-JP', (start.getDate() === end.getDate()) ? short : long);
return `${startString} 〜 ${endString}`;
}
get justifiedDateString(){
return this.justifiedDateToString(this.startAt) + ' 〜 ' + this.justifiedTimeToString(this.endAt);
}
get justifiedStartAtShortDateString(){
return this.justifiedShortDateToString(this.startAt);
}
get startAtString(){
return this.timeToString(this.startAt);
}
get endAtString(){
return this.timeToString(this.endAt);
}
get timeString(){
return this.startAtString + ' 〜 ' + this.endAtString;
}
get timeshiftString(){
let endAt = MyVideo.isPremiumUser() ? this.timeshiftEndAt : this.timeshiftFreeEndAt;
if(!endAt) return '';
let remain = endAt - MinuteStamp.justToday();
switch(true){
case(DAY*2 <= remain):
return `${Math.floor(remain/DAY)}日後の ${this.dateToString(endAt)} まで見逃し視聴できます`;
case(DAY <= remain):
return `あす ${this.dateToString(endAt)} の ${this.timeToString(endAt)} まで見逃し視聴できます`;
case(0 <= remain):
return `きょう ${this.dateToString(endAt)} の ${this.timeToString(endAt)} まで見逃し視聴できます`;
default:
return '';
}
}
hasNoContent(title){
return NOCONTENTS.some((frag) => title.match(frag));
}
dateToString(timestamp){
return new Date(timestamp * 1000).toLocaleDateString('ja-JP', {month: 'short', day: 'numeric', weekday: 'short'});
}
timeToString(timestamp){
return new Date(timestamp * 1000).toLocaleTimeString('ja-JP', {hour: 'numeric', minute: '2-digit'});
}
justifiedShortDateToString(timestamp){
/* toLocaleString('ja-JP')の2-digitが効かないバグがあるので */
let date = new Date(timestamp * 1000), d = {
date: ('00' + date.getDate()).slice(-2),
day: JDAYS[date.getDay()],
hours: ('00' + date.getHours()).slice(-2),
minutes: ('00' + date.getMinutes()).slice(-2),
};
return `${d.date}(${d.day}) ${d.hours}:${d.minutes}`;
}
justifiedDateToString(timestamp){
/* toLocaleString('ja-JP')の2-digitが効かないバグがあるので */
let date = new Date(timestamp * 1000), d = {
month: date.getMonth() + 1,
date: ('00' + date.getDate()).slice(-2),
day: JDAYS[date.getDay()],
hours: ('00' + date.getHours()).slice(-2),
minutes: ('00' + date.getMinutes()).slice(-2),
};
return `${d.month}月${d.date}日(${d.day}) ${d.hours}:${d.minutes}`;
}
justifiedTimeToString(timestamp){
let date = new Date(timestamp * 1000), d = {
hours: ('00' + date.getHours()).slice(-2),
minutes: ('00' + date.getMinutes()).slice(-2),
};
return `${d.hours}:${d.minutes}`;
}
}
class Thumbnail{
constructor(displayProgramId, name, size = 'small'){
const x = (window.innerWidth * PIXELRATIO < 960) ? 1 : 2;
const sizes = {/*解像度確保のためx2を指定させていただく*/
large: {q: 95, w: 256, h: 144, x: x},
small: {q: 95, w: 135, h: 76, x: x},
};
this.displayProgramId = displayProgramId;
this.name = name;
this.params = sizes[size];
}
get node(){
let img = document.createElement('img');
img.classList.add('loading');
img.addEventListener('load', function(){
img.classList.remove('loading');
});
img.src = THUMBIMG.replace(
'{displayProgramId}', this.displayProgramId
).replace(
'{name}', this.name
).replace(
'{q}', this.params.q
).replace(
'{w}', this.params.w
).replace(
'{h}', this.params.h
).replace(
'{x}', this.params.x
);
return img;
}
}
class MinuteStamp{
static now(){
let now = new Date(), minutes = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes());
return minutes.getTime() / 1000;
}
static past(){
let now = new Date(), minutes = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes());
return ((minutes.getTime() / 1000) + JST) % DAY;
}
static justToday(){
let now = new Date(), today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
return today.getTime() / 1000;
}
static timeToString(timestamp){
return new Date(timestamp * 1000).toLocaleTimeString('ja-JP', {hour: 'numeric', minute: '2-digit'});
}
static timeToClock(timestamp){
let time = new Date(timestamp * 1000);
return createElement(core.html.clock(time.getHours(), ('00' + time.getMinutes()).slice(-2)));
}
static minimumDateToString(timestamp){
let d = new Date(timestamp * 1000);
return `${d.getDate()}${JDAYS[d.getDay()]}`;
}
}
class Button{
static getOnceButtons(id){
return document.querySelectorAll(`button.notify[data-once="${id}"]`);
}
static getRepeatButtons(id){
return document.querySelectorAll(`button.notify[data-once][data-repeat="${id}"]`);
}
static getButtonTitle(button){
if(button.classList.contains('active')) return button.dataset.titleActive;
if(button.classList.contains('search')) return button.dataset.titleSearch;
if(button.classList.contains('repeat')) return button.dataset.titleRepeat;
if(button.classList.contains('once')) return button.dataset.titleOnce;
return button.dataset.titleDefault;
}
static addActive(button){
Button.reverse(button, 'add', 'active');
}
static removeActive(button){
Button.reverse(button, 'remove', 'active');
}
static addOnce(id){
Button.getOnceButtons(id).forEach((b) => Button.reverse(b, 'add', 'once'));
Slot.getOnceSlots(id).forEach((s) => Slot.highlight(s, 'add', 'active'));
}
static removeOnce(id){
Button.getOnceButtons(id).forEach((b) => Button.reverse(b, 'remove', 'once'));
Slot.getOnceSlots(id).forEach((s) => {if(!Notifier.match(s.dataset.once)) Slot.highlight(s, 'remove', 'active')});
}
static addRepeat(id){
Button.getRepeatButtons(id).forEach((b) => Button.reverse(b, 'add', 'repeat'));
Slot.getRepeatSlots(id).forEach((s) => Slot.highlight(s, 'add', 'active'));
}
static removeRepeat(id){
Button.getRepeatButtons(id).forEach((b) => Button.reverse(b, 'remove', 'repeat'));
Slot.getRepeatSlots(id).forEach((s) => {if(!Notifier.match(s.dataset.once)) Slot.highlight(s, 'remove', 'active')});
}
static addSearch(id){
Button.getOnceButtons(id).forEach((b) => Button.reverse(b, 'add', 'search'));
Slot.getOnceSlots(id).forEach((s) => Slot.highlight(s, 'add', 'active'));
}
static removeSearch(id){
Button.getOnceButtons(id).forEach((b) => Button.reverse(b, 'remove', 'search'));
Slot.getOnceSlots(id).forEach((s) => {if(!Notifier.match(s.dataset.once)) Slot.highlight(s, 'remove', 'active')});
}
static reverse(button, action, name){
button.classList.add('reversing');
button.addEventListener('transitionend', function(e){
button.classList[action](name);
button.classList.remove('reversing');
button.title = Button.getButtonTitle(button);
}, {once: true});
}
static shake(button){
button.animate([
{transform: 'translateX(-10%)'},
{transform: 'translateX(+10%)'},
], {
duration: 50,
iterations: 5,
});
}
static pop(button){
button.animate([/*放物線*/
{transform: 'translateY( +7%)'},
{transform: 'translateY( +6%)'},
{transform: 'translateY( +4%)'},
{transform: 'translateY( 0%)'},
{transform: 'translateY(-32%)'},
{transform: 'translateY(-48%)'},
{transform: 'translateY(-56%)'},
{transform: 'translateY(-60%)'},
{transform: 'translateY(-62%)'},
{transform: 'translateY(-63%)'},
{transform: 'translateY(-63%)'},
{transform: 'translateY(-62%)'},
{transform: 'translateY(-60%)'},
{transform: 'translateY(-56%)'},
{transform: 'translateY(-48%)'},
{transform: 'translateY(-32%)'},
{transform: 'translateY( 0%)'},
{transform: 'translateY(-16%)'},
{transform: 'translateY(-24%)'},
{transform: 'translateY(-28%)'},
{transform: 'translateY(-30%)'},
{transform: 'translateY(-31%)'},
{transform: 'translateY(-31%)'},
{transform: 'translateY(-30%)'},
{transform: 'translateY(-28%)'},
{transform: 'translateY(-24%)'},
{transform: 'translateY(-16%)'},
{transform: 'translateY( 0%)'},
{transform: 'translateY( +3%)'},
{transform: 'translateY( +2%)'},
{transform: 'translateY( 0%)'},
], {
duration: 750,
});
}
}
class Slot{
static getOnceSlots(id){
return document.querySelectorAll(`.slot[data-once="${id}"]`);
}
static getRepeatSlots(id){
return document.querySelectorAll(`.slot[data-repeat="${id}"]`);
}
static highlight(slot, action, name){
slot.classList.add('transition');
animate(function(){
slot.classList[action](name);
slot.addEventListener('transitionend', function(e){
slot.classList.remove('transition');
}, {once: true});
});
}
}
class Notifier{
static sync(){
if(!configs.n_sync) return;
let add = (type, id) => {
let updateLocal = (type, program) => {
switch(type){
case('once'):
notifications['once'][program.once] = Program.modifyTitle(normalize(program.title));
Notifier.updateOnceProgram(program);
break;
case('repeat'):
notifications['repeat'][program.repeat] = Program.modifyTitle(normalize(program.title));
Notifier.updateRepeatPrograms(program);
break;
}
Notifier.save();
};
let program = core.getProgramById(id);
if(program) return updateLocal(type, program);
/* 臨時チャンネル番組などでprogramが見つからないときはアベマに問い合わせる */
/* (最初からprogramデータ付きで取得するAPIオプション(&withDataSet=true)もあるけど無駄が多いので採用しない) */
let xhr = new XMLHttpRequest();
xhr.open('GET', site.get.apis.slot(id));
xhr.setRequestHeader('Authorization', 'bearer ' + site.get.token());
xhr.responseType = 'json';
xhr.onreadystatechange = function(){
if(xhr.readyState !== 4 || xhr.status !== 200) return;
if(!xhr.response.dataSet || !xhr.response.dataSet.slots) return log(`Not found: reservation data ${type} "${id}"`);
//log('xhr.response:', xhr.response);
let slot = xhr.response.dataSet.slots[0], channel = xhr.response.dataSet.channels[0];/*xhr.responseをそのまま使うとパフォーマンス悪い*/
slot.programs = xhr.response.dataSet.programs;
let program = new Program().fromSlot(slot, {id: channel.id, name: channel.name});
updateLocal(type, program);
};
xhr.send();
};
/* こっからsync処理 */
let xhr = new XMLHttpRequest();
xhr.open('GET', site.get.apis.reservations());
xhr.setRequestHeader('Authorization', 'bearer ' + site.get.token());
xhr.responseType = 'json';
xhr.onreadystatechange = function(){
if(xhr.readyState !== 4 || xhr.status !== 200) return;
if(!xhr.response.slots) return log(`Not found: reservations data`);
//log('xhr.response:', xhr.response);
let slots = xhr.response.slots;/*xhr.responseをそのまま使うとパフォーマンス悪い*/
/* あちらにしかないものはあちらで能動的に登録したとみなしてこちらにも登録 */
for(let i = 0; slots[i]; i++){
if(!slots[i].repetition && !Notifier.matchOnce(slots[i].slotId)){
if(Notifier.match(slots[i].slotId)) continue;/*こちらでは検索通知として登録済み*/
add('once', slots[i].slotId);
}else if(slots[i].repetition && !Notifier.matchRepeat(slots[i].slotGroupId)){
add('repeat', slots[i].slotId);
}
}
/* こちらにしかないものはあちらで能動的に削除したとみなしてこちらでも削除 */
let now = MinuteStamp.now();/*放送終了までは残す*/
Object.keys(notifications.once).forEach((key) => {
if(slots.some((slot) => slot.slotId === key)) return;/*1回か毎回かは問わずあちらにもある*/
let program = notifications.programs.find((p) => p.once === key);
if(program && (now < program.endAt)) return;/*あちらにないけどまだ放送中*/
delete notifications.once[key];
});
Object.keys(notifications.repeat).forEach((key) => {
if(slots.some((slot) => slot.slotGroupId === key)) return;/*あちらにもある*/
let program = notifications.programs.find((p) => p.repeat === key);
if(program && (now < program.endAt)) return;/*あちらにないけどまだ放送中*/
delete notifications.repeat[key];
});
notifications.programs = notifications.programs.filter((program) => {
if(slots.some((slot) => slot.slotId === program.id)) return true;/*あちらにもある*/
if(Notifier.matchSearch(program)) return true;/*検索通知として登録済み*/
if(now < program.endAt) return true;/*まだ放送中*/
});
Notifier.save();
};
xhr.send();
}
static addOnce(program){
if(Notifier.matchOnce(program.once)) return;
Notifier.add(program, 'once');
Notifier.updateOnceProgram(program);
Notifier.save();
}
static removeOnce(program){
Notifier.remove(program, 'once');
notifications.programs = notifications.programs.filter((p) => {
if(Notifier.matchOnce(p.once)) return true;
if(Notifier.matchRepeat(p.repeat)) return true;
if(Notifier.matchSearch(p)) return true;
});
Notifier.save();
}
static addRepeat(program){
if(Notifier.matchRepeat(program.repeat)) return;
Notifier.add(program, 'repeat');
Notifier.updateRepeatPrograms(program);
Notifier.save();
}
static removeRepeat(program){
Notifier.remove(program, 'repeat');
notifications.programs = notifications.programs.filter((p) => {
if(Notifier.matchOnce(p.once)) return true;
if(Notifier.matchRepeat(p.repeat)) return true;
if(Notifier.matchSearch(p)) return true;
});
Notifier.save();
}
static updateRepeatTitle(program){
notifications.repeat[program.repeat] = Program.getRepeatTitle(notifications.repeat[program.repeat], program.title);
}
static add(program, type){
Notification.requestPermission();
notifications[type][program[type]] = program.title;
if(configs.n_sync) Notifier.reserve(program[type], type);
}
static remove(program, type){
delete notifications[type][program[type]];
if(configs.n_sync) Notifier.unreserve(program[type], type);
}
static addSearch(key, marks){
Notification.requestPermission();
notifications.search[key] = marks;
let matchIds = Notifier.updateSearchPrograms(key, marks);
Notifier.save();
return matchIds;/*通知ボタンくるりんぱ用*/
}
static removeSearch(key, marks){
delete notifications.search[key];
let unmatchIds = [];
notifications.programs = notifications.programs.filter((p) => {
if(Notifier.matchSearch(p)) return true;
unmatchIds.push(p.id);/*今回searchの対象から外れたid*/
if(Notifier.matchRepeat(p.repeat)) return true;
if(Notifier.matchOnce(p.once)) return true;
if(configs.n_sync) Notifier.unreserve(p.id, 'once');/*公式に検索通知がないので1回通知として削除する*/
});
Notifier.save();
return unmatchIds;/*通知ボタンくるりんぱ用*/
}
static reserve(id, type){
notifications.requests = notifications.requests.filter((r) => r.id !== id);/*既に予定済みなら上書きするのでいったん削除*/
notifications.requests.push({action: 'PUT', id: id, type: type});
}
static unreserve(id, type){
notifications.requests = notifications.requests.filter((r) => r.id !== id);/*既に予定済みなら上書きするのでいったん削除*/
notifications.requests.push({action: 'DELETE', id: id, type: type});
}
static request(){
if(!configs.n_sync || !notifications.requests[0]) return;/*リクエスト予定なし*/
let request = notifications.requests[0], action = request.action, id = request.id, type = request.type;/*1つずつしか処理しない*/
/* APIから通知を予約する */
let xhr = new XMLHttpRequest();
xhr.open(action, site.get.apis.reservation(id, type));
xhr.setRequestHeader('Authorization', 'bearer ' + site.get.token());
if(DEBUG) xhr.onreadystatechange = function(){
if(xhr.readyState !== 4 || xhr.status !== 200) return;
log('xhr.response:', xhr.response);
};
xhr.send();
/* リクエストキューを削除 */
notifications.requests.shift();
Notifier.save();
}
static updateOnceProgram(program){
let now = MinuteStamp.now();
if(program.startAt < now) return;/*放送中・終了した番組は登録しない*/
if(Notifier.match(program.id)) return;/*既に通知予定済み*/
notifications.programs.push(program);
notifications.programs.sort((a, b) => a.startAt - b.startAt);
}
static updateRepeatPrograms(program){
/* channelsに含まれない臨時チャンネルの番組もあるので先に登録を済ませておく */
if(!Notifier.match(program.id)){
notifications.programs.push(program);
Notifier.updateRepeatTitle(program);
}
/* channelsから該当する番組を登録する */
for(let c = 0, now = MinuteStamp.now(); channels[c]; c++){
for(let p = 0, target; target = channels[c].programs[p]; p++){
if(target.startAt < now) continue;/*放送中・終了した番組は登録しない*/
if(!target.repeat || target.repeat !== program.repeat) continue;/*検証対象のidではない*/
if(Notifier.match(target.id)) continue;/*既に通知予定済み*/
notifications.programs.push(target);
Notifier.updateRepeatTitle(target);
}
}
notifications.programs.sort((a, b) => a.startAt - b.startAt);
}
static updateSearchPrograms(key, marks){
let matchIds = [], now = MinuteStamp.now();
for(let c = 0; channels[c]; c++){
for(let p = 0, program; program = channels[c].programs[p]; p++){
if(program.startAt < now) continue;/*放送中・終了した番組は登録しない*/
if(!core.matchProgram(program, key, marks)) continue;/*key,marksに該当しない番組はもちろん登録しない*/
matchIds.push(program.id);/*このkey,marksに該当するid*/
if(Notifier.match(program.id)) continue;/*programsに重複登録はしない(onceやrepeat,または既存searchによって登録済み)*/
notifications.programs.push(program);
if(configs.n_sync) Notifier.reserve(program.id, 'once');/*公式に検索通知がないので1回通知として登録する*/
}
}
notifications.programs.sort((a, b) => a.startAt - b.startAt);
return matchIds;
}
static updateAllPrograms(){
Object.keys(notifications.repeat).forEach((repeat) => Notifier.updateRepeatPrograms(notifications.programs.find((p) => p.repeat === repeat)));
Object.keys(notifications.search).forEach((key) => Notifier.updateSearchPrograms(key, notifications.search[key]));
Notifier.save();
}
static matchOnce(once){
return notifications.once[once];
}
static matchRepeat(repeat){
return notifications.repeat[repeat];
}
static matchSearch(program){
return Object.keys(notifications.search).find((key) => core.matchProgram(program, key, notifications.search[key]));
}
static match(id){
if(notifications.programs.some((p) => p.id === id)) return true;
}
static createPlayButton(program){
let button = createElement(core.html.playButton());
button.classList.add('channel-' + program.channel.id);
if(location.href.endsWith('/now-on-air/' + program.channel.id)) button.classList.add('current');
button.addEventListener('click', Notifier.playButtonListener.bind(program));
return button;
}
static playButtonListener(e){
let program = this, button = e.target/*playButtonListener.bind(program)*/;
core.goChannel(program.channel.id);
e.stopPropagation();
}
static createRepeatAllButton(program){
let button = createElement(core.html.repeatAllButton());
if(Notifier.matchRepeat(program.repeat)){
button.classList.add('active');
button.title = button.dataset.titleActive;
}else{
button.title = button.dataset.titleDefault;
}
button.dataset.repeat = program.repeat;
button.addEventListener('click', Notifier.repeatAllButtonListener.bind(program));
return button;
}
static repeatAllButtonListener(e){
let program = this, button = e.target/*repeatAllButtonListener.bind(program)*/;
switch(true){
case(button.classList.contains('active')):
Notifier.removeRepeat(program);
Button.removeActive(button);
Button.removeRepeat(program.repeat);
break;
default:
Notifier.addRepeat(program);
Button.addActive(button);
Button.addRepeat(program.repeat);
break;
}
}
static createNotifyButton(program){
let button = createElement(core.html.notifyButton());
if(Notifier.matchOnce(program.once)) button.classList.add('once');
if(Notifier.matchRepeat(program.repeat)) button.classList.add('repeat');
let key = Notifier.matchSearch(program);
if(key){
button.classList.add('search');
button.dataset.key = key;
}
button.title = Button.getButtonTitle(button);
button.dataset.once = program.once;
if(program.repeat) button.dataset.repeat = program.repeat;
button.addEventListener('click', Notifier.notifyButtonListener.bind(program));
return button;
}
static notifyButtonListener(e){
let program = this, button = e.target/*notifyButtonListener.bind(program)*/, searchKey = button.dataset.key;
let updateSearchPane = () => {
if(!elements.searchPane || !elements.searchPane.isConnected) return;
if(elements.searchPane.dataset.mode !== 'notifications') return;
for(let target = button.parentNode; target; target = target.parentNode){
if(target === elements.searchPane) return;/*searchPaneでのクリック時はなにもしない*/
}
core.timetable.searchPane.buildNotificationsHeader();
core.timetable.searchPane.listAllNotifications();
};
switch(true){
case(searchKey !== undefined):
if(!elements.timetablePanel.isConnected) core.timetable.createPanel();
core.timetable.searchPane.search(searchKey, notifications.search[searchKey]);
Button.shake(button);
break;
case(Notifier.matchRepeat(program.repeat) !== undefined):
Button.shake(button);
break;
case(Notifier.matchOnce(program.once) !== undefined):
Notifier.removeOnce(program);
Button.removeOnce(program.once);
updateSearchPane();
break;
default:
Notifier.addOnce(program);
Button.addOnce(program.once);
updateSearchPane();
break;
}
e.preventDefault();
e.stopPropagation();
}
static createSearchButton(key){
let button = createElement(core.html.notifyButton());
button.classList.add('search');
button.dataset.key = key;
button.title = Button.getButtonTitle(button);
button.addEventListener('click', Notifier.searchButtonListener);
return button;
}
static searchButtonListener(e){
let button = e.target/*notifyButtonListener.bind(key)*/, searchKey = button.dataset.key;
if(!elements.timetablePanel.isConnected) core.timetable.createPanel();
core.timetable.searchPane.search(searchKey, notifications.search[searchKey]);
Button.shake(button);
e.preventDefault();
e.stopPropagation();
}
static createButton(program){
let now = MinuteStamp.now();
if(program.startAt <= now) return Notifier.createPlayButton(program);
if(now < program.startAt) return Notifier.createNotifyButton(program);
}
static createSearchAllButton(key, marks){
let button = createElement(core.html.searchAllButton(key, marks.map((name) => core.html.marks[name]()).join('')));
if(notifications.search[key] && notifications.search[key].join() === marks.join()) button.classList.add('active');
button.addEventListener('click', Notifier.searchAllButtonListener.bind({key: key, marks: marks}));
return button;
}
static searchAllButtonListener(e){
let key = this.key, marks = this.marks, button = e.target/*searchAllButtonListener.bind({key: key, marks: marks})*/;
switch(true){
case(notifications.search[key] && notifications.search[key].join() === marks.join()):
Notifier.removeSearch(key, marks).forEach((id) => Button.removeSearch(id));
Button.removeActive(button);
break;
default:
Notifier.addSearch(key, marks).forEach((id) => Button.addSearch(id));
Button.addActive(button);
break;
}
core.timetable.searchPane.updateSearchFillters(key, marks);
Notifier.save();
}
static notify(){
/* notifications.programsを確認して通知を表示する。番組が終了するまでprogramは保持しておく */
let now = Date.now() / 1000, buffer = location.href.includes('/now-on-air/') ? 1000 : 0;/*視聴ページでの通知を優先させるための工夫*/
for(let p = 0, programs = notifications.programs; programs[p]; p++){
let program = programs[p], closeMe;
if(now < program.startAt - (configs.n_before + buffer/1000)) return;/*まだ通知時刻じゃない(後続のprogramも同様なのでreturn)*/
/* 複数タブで通知させないための工夫 */
let ns = Storage.read('notifications');/*先にprogramを通知していないか確認する*/
switch(true){
case(ns.programs.length !== programs.length):/*ほかで終了番組を削除済み*/
case(ns.programs[p].notification && !program.notification):/*ほかで通知済み*/
notifications = ns;
for(let i = 0; notifications.programs[i]; i++) notifications.programs[i] = new Program(notifications.programs[i]);
return;
}
setTimeout(function(){
/* 通知時刻になっている */
if(!program.notification){/*未通知*/
switch(true){
case(!configs.c_visibles[program.channel.id]):/*非表示チャンネル*/
if(program.endAt <= now) programs = programs.filter((p) => p.id !== program.id), p--;
break;
case(now < program.startAt):/*通知時刻*/
case(now < program.endAt):/*放送中*/
program.notification = new Notification(program.title, {
body: `${program.timeString} ${program.channel.name}`,
});
closeMe = program.notification.close.bind(program.notification);
program.notification.addEventListener('click', function(e){
core.goChannel(program.channel.id);
closeMe();
});
if(configs.n_change && (!configs.n_overlap || p === 0)){
window.addEventListener('beforeunload', closeMe);/*通知が開いたままになるのを防ぐ*/
core.goChannel(program.channel.id);/*ページ遷移が発生した場合に即閉じられるのはやむを得ない*/
setTimeout(function(){
window.removeEventListener('beforeunload', closeMe);
closeMe();
}, (Math.max(program.startAt - now, 0) + NOTIFICATIONREMAINS)*1000);/*番組開始時刻+REMAINSまで*/
}else{
setTimeout(function(){
closeMe();
}, (Math.max(program.endAt - now, 0))*1000);/*番組終了時刻まで*/
}
break;
case(now < program.endAt + NOTIFICATIONAFTER):/*手遅れ*/
program.notification = new Notification(program.title, {
body: `[放送終了] ${program.channel.name}`,
});
setTimeout(function(){program.notification.close()}, NOTIFICATIONREMAINS*1000);
break;
}
}else{/*通知済み*/
if(now < program.endAt) return;/*まだ番組は続いている*/
/* 番組が終了したようなので */
programs = programs.filter((p) => p.id !== program.id), p--;
if(programs[0] && programs[0].notification && configs.n_change){/*別の通知番組がまだ放送中である*/
core.goChannel(programs[0].channel.id);
if(programs[0].notification.close){
setTimeout(function(){programs[0].notification.close()}, NOTIFICATIONREMAINS*1000);
}
}
}
notifications.programs = programs;/*参照じゃなかったの?という気もするけどこうしないと保存されない*/
Notifier.save();
}, buffer);
}
}
static updateCount(){
let button = elements.notificationsButton;
if(!button) return;
if(parseInt(button.dataset.count) === notifications.programs.length) return;
button.dataset.count = button.querySelector('.count').textContent = notifications.programs.length;
Button.pop(button);
}
static save(){
Notifier.updateCount();
Storage.save('notifications', notifications);
}
}
class MyVideo{
static sync(){
/* 視聴期限切れもあちらで消えるので自動的に反映される */
let xhr = new XMLHttpRequest();
xhr.open('GET', site.get.apis.favorites());
xhr.setRequestHeader('Authorization', 'bearer ' + site.get.token());
xhr.responseType = 'json';
xhr.onreadystatechange = function(){
if(xhr.readyState !== 4 || xhr.status !== 200) return;
if(!xhr.response.slots) return log(`Not found: data`);
//log('xhr.response:', xhr.response);
let slots = xhr.response.dataSet.slots;/*xhr.responseをそのまま使うとパフォーマンス悪い*/
/* あちらにしかないものはあちらで能動的に登録したとみなしてこちらにも登録 */
for(let i = 0; slots[i]; i++){
if(!myvideos.some((p) => p.id === slots[i].id)){
let program = core.getProgramById(slots[i].id);
if(program) myvideos.push(program);
}
}
/* こちらにしかないものはあちらで能動的に削除したとみなしてこちらでも削除 */
myvideos = myvideos.filter((myvideo) => (slots.some((slot) => slot.id === myvideo.id)));
/* 更新 */
myvideos.sort((a, b) => a.startAt - b.startAt);
Storage.save('myvideos', myvideos);
};
xhr.send();
}
static add(program){
if(MyVideo.match(program.id)) return;
myvideos.push(program);
myvideos.sort((a, b) => a.startAt - b.startAt);
Storage.save('myvideos', myvideos);
MyVideo.request('PUT', program.id);
}
static remove(program){
myvideos = myvideos.filter((p) => (p.id !== program.id));
Storage.save('myvideos', myvideos);
MyVideo.request('DELETE', program.id);
}
static request(action, id){
/* APIから通知を予約する */
let xhr = new XMLHttpRequest();
xhr.open(action, site.get.apis.favorite(id));
xhr.setRequestHeader('Authorization', 'bearer ' + site.get.token());
if(DEBUG) xhr.onreadystatechange = function(){
if(xhr.readyState !== 4 || xhr.status !== 200) return;
log('xhr.response:', xhr.response);
};
xhr.send();
}
static match(id){
if(myvideos.some((p) => p.id === id)) return true;
}
static createMyvideoButton(program){
let button = createElement(core.html.myvideoButton());
if(MyVideo.match(program.id)){
button.classList.add('active');
button.title = button.dataset.titleActive;
}else{
button.title = button.dataset.titleDefault;
}
button.addEventListener('click', MyVideo.buttonListener.bind(program));
return button;
}
static buttonListener(e){
let program = this, button = e.target;/*buttonListener.bind(program)*/
switch(true){
case(MyVideo.match(program.id)):
MyVideo.remove(program);
Button.removeActive(button);
break;
default:
MyVideo.add(program);
Button.addActive(button);
break;
}
e.stopPropagation();
}
static isPremiumUser(){
return (site.get.subscriptionType() !== 'freeUser');
}
}
let html, elements = {}, configs = {};
let channels = [], myvideos = [], notifications = {
once: {},/*1回通知{id: title}*/
repeat: {},/*毎回通知{id: title}*/
search: {},/*検索通知{key: marks}*/
programs: [],/*通知予定{program}*/
requests: [],/*リクエスト予定{action, id, type}*/
};
let core = {
initialize: function(){
/* 一度だけ */
html = document.documentElement;
html.classList.add(SCRIPTNAME);
core.config.read();
core.read();
core.addStyle();
core.panel.createPanels();
core.listenUserActions();
core.ticktock();
core.abemaTimetable.initialize();
Notifier.updateAllPrograms();
},
ticktock: function(){
let last = new Date(), now = new Date();
setInterval(function(){
last = now, now = new Date();
switch(true){
/* 毎日処理 */
case (now.getDate() !== last.getDate()):
core.updateChannels();
core.timetable.buildTimes();
core.timetable.buildDays();/*先に作ったtimesのdisable判定を含む*/
/* 毎時処理 */
case (now.getHours() !== last.getHours()):
if(now.getDate() === last.getDate()) core.checkChannels();
Notifier.sync();
MyVideo.sync();
/* 毎分処理 */
case (now.getMinutes() !== last.getMinutes()):
core.timetable.shiftTimetable();
/* 毎秒処理 */
default:
core.checkUrl();
Notifier.notify();
Notifier.request();
core.checkStalled();
}
}, 1000);
},
checkUrl: function(){
location.previousUrl = location.previousUrl || '';
if(location.href === location.previousUrl) return;/*URLが変わってない*/
if(location.href.startsWith('https://abema.tv/now-on-air/')){/*テレビ視聴ページ*/
if(location.previousUrl.startsWith('https://abema.tv/now-on-air/')){/*チャンネルを変えただけ*/
elements.closer = site.get.closer();
}else{/*テレビ視聴ページになった*/
core.ready();
}
}else if(location.href.startsWith('https://abema.tv/timetable')){/*番組表ページ*/
if(location.previousUrl === '') core.abemaTimetable.openOnAbemaTimetable();/*初回のみ*/
}else{
/*nothing*/
}
location.previousUrl = location.href;
},
read: function(){
/* ストレージデータの取得とクラスオブジェクト化 */
channels = Storage.read('channels') || channels;
for(let i = 0; channels[i]; i++) channels[i] = new Channel(channels[i]);
if(!channels.length) core.updateChannels();
else if(DEBUG && UPDATECHANNELS) core.updateChannels();
notifications = Storage.read('notifications') || notifications;
for(let i = 0; notifications.programs[i]; i++) notifications.programs[i] = new Program(notifications.programs[i]);
myvideos = Storage.read('myvideos') || myvideos;
for(let i = 0; myvideos[i]; i++) myvideos[i] = new Program(myvideos[i]);
Notifier.sync();
MyVideo.sync();
},
ready: function(){
/* 必要な要素が出揃うまで粘る */
for(let i = 0, target; target = site.targets[i]; i++){
if(target() === false){
if(!retry) return log(`Not found: ${target.name}, I give up.`);
log(`Not found: ${target.name}, retrying...`);
return retry-- && setTimeout(core.ready, 1000);
}
}
elements.closer = site.get.closer();
log("I'm Ready.");
core.timetable.createButton();
core.channelPane.observe();
/* clickイベントを統括するScreenCommentScrollerの準備完了を待つ必要がある */
setTimeout(function(){
if(!site.get.screenCommentScroller()) return;
/* チャンネル切り替えイベントをいつでも流用するための準備 */
if(elements.channelPane.getAttribute('aria-hidden') === 'false') return;/*既に開かれている*/
/* 裏番組一覧が開かれたら即閉じる準備 */
let observer = observe(elements.channelPane.firstElementChild, function(records){
if(elements.channelPane.getAttribute('aria-hidden') === 'true') return;
observer.disconnect();/*一度だけ*/
core.channelPane.modify();/*idなどを付与する*/
elements.closer.click();
setTimeout(function(){html.classList.remove('channelPaneHidden')}, 1000);/*チラ見せさせない*/
});
core.channelPane.openHide();
}, 1000);
},
checkChannels: function(){
let xhr = new XMLHttpRequest();
xhr.open('GET', site.get.apis.channels());
xhr.responseType = 'json';
xhr.onreadystatechange = function(){
if(xhr.readyState !== 4 || xhr.status !== 200) return;
if(!xhr.response.channels) return log(`Not found: data`);
log('xhr.response:', xhr.response);
let cs = xhr.response.channels;/*xhr.responseをそのまま使うとパフォーマンス悪い*/
if(!cs.every((c) => channels.some((channel) => channel.id === c.id))) core.updateChannels();
};
xhr.send();
},
updateChannels: function(callback){
let xhr = new XMLHttpRequest();
xhr.open('GET', site.get.apis.timetable());
xhr.setRequestHeader('Authorization', 'bearer ' + site.get.token());
xhr.responseType = 'json';
xhr.onreadystatechange = function(){
if(xhr.readyState !== 4 || xhr.status !== 200) return;
if(!xhr.response.channels || !xhr.response.channelSchedules) return log(`Not found: data`);
log('xhr.response:', xhr.response);
let ss = xhr.response.channelSchedules, cs = xhr.response.channels, slots = {};/*xhr.responseをそのまま使うとパフォーマンス悪い*/
/* configs.c_visibles更新 */
if(Object.keys(configs.c_visibles).length === 0){
for(let i = 0, c; c = cs[i]; i++) configs.c_visibles[c.id] = 1;
}else{
for(let i = 0, c; c = cs[i]; i++){
if(configs.c_visibles[c.id] === undefined) configs.c_visibles[c.id] = 1;/*新規チャンネル*/
}
Object.keys(configs.c_visibles).forEach((key) => {
if(configs.c_visibles[key] === 0) return;/*非表示にした情報は残す*/
if(!cs.some((c) => c.id === key)) delete configs.c_visibles[key];/*非表示でなければ将来復活しても表示されるだけなので廃止チャンネルとみなしてかまわない*/
});
}
Storage.save('configs', configs);
/* channels更新 */
channels = [];/* いったんクリア */
for(let i = 0, s; s = ss[i]; i++){
slots[s.channelId] = (slots[s.channelId]) ? slots[s.channelId].concat(s.slots) : s.slots;
}
for(let i = 0, c; c = cs[i]; i++){
channels[i] = new Channel().fromChannelSlots(c, slots[c.id]);
}
/* 反映 */
Storage.save('channels', channels, MinuteStamp.justToday()*1000 + CACHEEXPIRE);/*1週間分で3MBくらいあるのでキャッシュする*/
core.addStyle();/*チャンネル数によってフォントサイズを変えるので*/
core.timetable.rebuildTimetable();
Notifier.updateAllPrograms();
if(callback) callback();
};
xhr.send();
},
goChannel: function(id){
if(location.href.endsWith('/now-on-air/' + id)) return;/*すでに目的のチャンネルにいる*/
switch(true){
/* 番組視聴ページにいる */
case(elements.channelPane && elements.channelPane.isConnected):
/* 裏番組一覧から正規のチャンネル切り替えイベントを流用する */
let a = elements.channelPane.querySelector(`a[data-channel="${id}"]`);
if(a === null) return location.assign('/now-on-air/' + id);
a.click();
core.updateCurrentChannel(id);
return true;
/* 置き換えた公式番組表ページにいる */
case(configs.replace && location.href.endsWith('/timetable')):
core.abemaTimetable.goChannel(id);
return true;
default:
return location.assign('/now-on-air/' + id);
}
},
skipChannel: function(direction = +1){
if(!location.href.includes('/now-on-air/')) return;
let loop = (i) => {
switch(true){
case(direction === +1):
if(i === channels.length - 1) return 0;
else return i + 1;
case(direction === -1):
if(i === 0) return channels.length - 1;
else return i - 1;
}
};
for(let c = 0; channels[c]; c++){
if(!location.href.endsWith('/now-on-air/' + channels[c].id)) continue;
for(let i = loop(c), target; target = channels[i]; i = loop(i)){
if(configs.c_visibles[target.id]){
if(i === loop(c)){
core.updateCurrentChannel(target.id);
return false;/*スキップ不要*/
}else{
core.goChannel(target.id);
return true;/*スキップした*/
}
}
if(target === channels[c]) return false;/*一周してしまった*/
}
}
},
updateCurrentChannel(id){
/* playButtonのcurrentを付け替える */
$$('button.play.current').forEach((b) => b.classList.remove('current'));
$$('button.play.channel-' + id).forEach((b) => b.classList.add('current'));
/* channelPaneのcurrentを付け替える */
if(elements.channelPane){
let previous = elements.channelPane.querySelector('a[data-current="true"]');
if(previous) delete previous.dataset.current;
let current = elements.channelPane.querySelector(`a[data-channel="${id}"]`);
if(current) current.dataset.current = 'true';/*classは公式にclassNameで上書きされてしまうので*/
core.channelPane.scroll(current);
}
/* channelsUlのcurrentを付け替える */
if(elements.channelsUl){
let previous = elements.channelsUl.querySelector('.channel.current');
if(previous) previous.classList.remove('current');
let current = elements.channelsUl.querySelector('.channel#channel-' + id);
if(current) current.classList.add('current');
}
},
listenUserActions: function(){
window.addEventListener('click', function(e){
switch(true){
/* チャンネル切り替えボタン */
case(e.target === elements.channelUpButton):
case(e.target === elements.channelDownButton):
if(core.skipChannel((e.target === elements.channelDownButton) ? +1 : -1)){
e.stopPropagation();
e.preventDefault();
}
/*skip不要ならデフォルトのチャンネル切り替えに任せる*/
return;
/* アベマ公式の通知・マイビデオボタンが押されたら同期する */
case(e.isTrusted && configs.n_sync && site.get.abemaNotifyButton(e.target)):
return setTimeout(Notifier.sync, 1000);
case(e.isTrusted && site.get.abemaMyVideoButton(e.target)):
return setTimeout(MyVideo.sync, 1000);
}
}, true);
window.addEventListener('keydown', function(e){
if(!location.href.includes('/now-on-air/')) return;
switch(true){
/* テキスト入力中は反応しない */
case(['input', 'textarea'].includes(document.activeElement.localName)):
return;
/* Alt/Shift/Ctrl/Metaキーが押されていたら反応しない */
case(e.altKey || e.shiftKey || e.ctrlKey || e.metaKey):
return;
/* 上下キーによるチャンネル切り替え */
case(e.key === 'ArrowUp'):
case(e.key === 'ArrowDown'):
document.activeElement.blur();/*上下キーによるスクロールを防止*/
if(core.skipChannel((e.key === 'ArrowDown') ? +1 : -1)){
e.stopPropagation();
e.preventDefault();
}
/*skip不要ならデフォルトのチャンネル切り替えに任せる*/
return;
}
}, true);
let resize = function(){
core.channelPane.modify();
core.timetable.fitWidth();
};
window.addEventListener('resize', function(e){
if(!window.resizing) resize();
clearTimeout(window.resizing), window.resizing = setTimeout(function(){
resize();
window.resizing = null;
}, 500);
});
},
checkStalled: function(){
if(document.hidden || !location.href.includes('/now-on-air/')) return;
/* 連続で再読込した時はもう少し粘る */
let limit = Storage.read('stalledlimit') || STALLEDLIMIT, expire = Date.now() + 1000*60;
/* main消失バグに対応 */
let main = $('#main');
if(!main) return;
if(main.children.length === 0){
if(!main.loadedOnce) return;/*ページ読み込み直後は猶予する*/
main.vanishedCount = (main.vanishedCount || 0) + 1;
log('Vanished?', main.vanishedCount);
if(limit <= main.vanishedCount){
Storage.save('stalledlimit', limit + 1, expire);
return location.reload();
}
}else{
if(!main.loadedOnce) main.loadedOnce = true;
if(1 <= main.vanishedCount){
main.vanishedCount--;
log('Recovering vanished?', main.vanishedCount);
}
}
/* 映像または音声の停止を検知する */
let videos = $$('video[src]'), audios = $$('audio[src]'), progress = 0.5/*1秒の間に最低限進んでいなければならない秒数*/;
if(!videos.length) return;
switch(true){
case(Array.from(videos).every((v) => v.paused)):
case(audios.length && Array.from(audios).every((a) => a.paused)):
case(Array.from(videos).some((v) => !v.paused && (v.currentTime - v.previousTime < progress))):
case(audios.length && Array.from(audios).some((a) => !a.paused && (a.currentTime - a.previousTime < progress))):
videos[0].pausedCount = (videos[0].pausedCount || 0) + 1;
log('Paused?', videos[0].pausedCount);
if(limit <= videos[0].pausedCount){
Storage.save('stalledlimit', limit + 1, expire);
return location.reload();
}
break;
default:
if(1 <= videos[0].pausedCount){
videos[0].pausedCount--;
log('Recovering paused?', videos[0].pausedCount);
}
break;
}
},
channelPane: {
observe: function(){
/* 裏番組一覧を常に改変する */
if(elements.channelPane.modifying === undefined) observe(elements.channelPane, function(records){
if(elements.channelPane.modifying) return;
elements.channelPane.modifying = true;
core.channelPane.modify();/*アベマによる更新を上書きする*/
animate(function(){elements.channelPane.modifying = false});/*DOM処理の完了後に*/
}, {childList: true, characterData: true, subtree: true});
},
openHide: function(){
/* ユーザーには閉じたように見せない */
html.classList.add('channelPaneHidden');/*開いても隠しておく*/
elements.channelButton.click();
},
scroll: function(a){
let channelPane = elements.channelPane, child = channelPane.firstElementChild;
let pHeight = child.offsetHeight, aTop = a.offsetTop, aHeight = a.offsetHeight, innerHeight = window.innerHeight;
if(pHeight <= innerHeight) return;
let scrollTop = Math.min(Math.max(aTop - (innerHeight / 2) + (aHeight / 2), 0), pHeight - innerHeight -1/*端数対応*/);
child.style.transition = 'none';
child.style.transform = `translateY(${scrollTop - channelPane.scrollTop}px)`;
channelPane.scrollTop = scrollTop;
animate(function(){
child.style.transition = 'transform 500ms ease';
child.style.transform = '';
});
},
modify: function(){
if(!elements.channelPane) return;
let channelPane = elements.channelPane, as = site.get.onairs(channelPane), nowonairs = {}/*チャンネルテーブル*/;
if(!as.length) return;/*再挑戦のタイミングはobserverに任せる*/
for(let i = 0, a; a = as[i]; i++){
/* 臨時チャンネルが増えていればchannelsを更新する */
if(!channels.some((c) => c.id === a.href.match(/\/([^/]+)$/)[1])) return core.updateChannels(core.channelPane.modify);
/* サムネイルサイズの固定値(vw)を求める */
let thumbnail = site.get.thumbnail(a);
channelPane.thumbWidth = thumbnail.clientWidth || channelPane.thumbWidth;
channelPane.thumbOffsetWidth = thumbnail.parentNode.parentNode.parentNode.offsetWidth || channelPane.thumbOffsetWidth;
}
/* 各チャンネル */
/* classは公式にclassNameで上書きされてしまうのでdatasetを使う */
for(let i = 0, a; a = as[i]; i++){
nowonairs[a.href.match(/\/([^/]+)$/)[1]] = site.get.nowonair(a);
a.dataset.channel = a.href.match(/\/([^/]+)$/)[1];
a.dataset.hidden = (configs.c_visibles[a.dataset.channel]) ? 'false' : 'true';
/* 現在のチャンネルをハイライト */
if(location.href.endsWith(a.href)){
a.dataset.current = 'true';
core.channelPane.scroll(a);/*しかるべきスクロール位置へ*/
}else if(a.dataset.current){
delete a.dataset.current;
}
/* クリックでのチャンネル切り替えに対応 */
if(!a.listening){
a.listening = true;
a.addEventListener('click', function(e){
if(e.isTrusted){
e.preventDefault();
e.stopPropagation();
core.goChannel(a.dataset.channel);/*その後の処理もすべておまかせ*/
}
});
}
}
/* 後続番組を重ねる */
let now = MinuteStamp.now(), end = now + HOUR, ratio = (channelPane.offsetWidth - channelPane.thumbWidth) / HOUR, vw = 100 / window.innerWidth;
for(let c = 0, channel; channel = channels[c]; c++){
let nowonair = nowonairs[channel.id];
if(!nowonair) continue;/*臨時チャンネルはnowonairsに入ってない場合がある*/
while(nowonair.nextElementSibling) nowonair.parentNode.removeChild(nowonair.nextElementSibling);/*いったん中身をクリアする*/
for(let p = 0, program; program = channel.programs[p]; p++){
/* 現在からendまでの番組のみ表示させる */
if(program.endAt < now) continue;/*過去*/
if(end < program.startAt) break;/*未来*/
if(program.startAt <= now){/*現在放送中*/
/* タイトルの不要文字列を後まわしに */
let title = site.get.title(nowonair);
title.textContent = nowonair.title = Program.modifyTitle(normalize(title.textContent)) || ' ';
/* タイトルをツールチップにも */
nowonair.title = title.textContent;
/* コンテンツなし */
if(program.noContent) nowonair.classList.add('nocontent');
/* 放送時間を簡略化 */
let duration = site.get.duration(nowonair);
duration.textContent = Program.modifyDuration(duration.textContent);
nowonair.style.left = channelPane.thumbOffsetWidth * vw + 'vw';
nowonair.style.width = (program.duration - (now - program.startAt)) * ratio * vw + 'vw';
nowonair.previousElementSibling.title = program.title;/*サムネイルのツールチップ*/
nowonair.dataset.once = program.id;
if(program.repeat) nowonair.dataset.repeat = program.repeat;
if(Notifier.match(program.id)) nowonair.classList.add('active');
nowonair.classList.add('slot');
continue;
}
/* 以下後続番組 */
let slot = nowonair.cloneNode(true);
/* 要素幅 */
slot.style.left = (channelPane.thumbOffsetWidth + ((program.startAt - now) * ratio)) * vw + 'vw';
slot.style.width = (program.duration) * ratio * vw + 'vw';
/* タイトル */
let title = site.get.title(slot);
title.textContent = program.title || NOCONTENTS[0];
slot.title = program.title;/*ツールチップ*/
slot.classList.add('slot');
slot.classList[(program.noContent ? 'add' : 'remove')]('nocontent');
/* マーク対応 */
Array.from(title.parentNode.children).forEach((node) => {
if(node !== title) title.parentNode.removeChild(node);/*マークをいったん取り除く*/
});
Program.appendMarks(title, program.marks);
/* 放送時間と通知 */
let duration = site.get.duration(slot);
duration.textContent = program.timeString;
slot.dataset.once = program.id;
if(program.repeat) slot.dataset.repeat = program.repeat;
else delete slot.dataset.repeat;
if(Notifier.match(program.id)) slot.classList.add('active');
else slot.classList.remove('active');
if(!program.noContent) duration.parentNode.insertBefore(Notifier.createButton(program), duration);
nowonair.parentNode.appendChild(slot);
}
}
},
},
abemaTimetable: {
initialize: function(){
site.get.abemaTimetableButton();
let abemaTimetable = elements.abemaTimetableButton;
if(!abemaTimetable) return setTimeout(core.abemaTimetable.initialize, 1000);
if(configs.replace){
if(abemaTimetable.dataset.replaced === 'true') return;
abemaTimetable.addEventListener('click', core.abemaTimetable.buttonListener);
abemaTimetable.dataset.replaced = 'true';
}else{
if(abemaTimetable.dataset.replaced === 'false') return;
abemaTimetable.removeEventListener('click', core.abemaTimetable.buttonListener);
abemaTimetable.dataset.replaced = 'false';
}
},
openOnAbemaTimetable: function(){
html.classList.add('abemaTimetable');
$('#splash > div').animate([{opacity: '0'}, {opacity: '1'}], {duration: ABEMATIMETABLEDURATION, fill: 'forwards'});
core.panel.toggle('timetablePanel', core.timetable.createPanel);
core.timetable.addCloseListener('closeOnAbemaTimetable', core.abemaTimetable.closeOnAbemaTimetable);
},
closeOnAbemaTimetable: function(e){
sequence(function(){
$('#splash > div').animate([{opacity: '1'}, {opacity: '0'}], {duration: ABEMATIMETABLEDURATION, fill: 'forwards'});
}, ABEMATIMETABLEDURATION, function(){
html.classList.remove('abemaTimetable');
});
},
buttonListener: function(e){
if(location.href.startsWith('https://abema.tv/timetable')){
e.preventDefault();
core.abemaTimetable.openOnAbemaTimetable();
return;
}
if(e.isTrusted){/*実クリック時のみ*/
e.preventDefault();
core.abemaTimetable.volumeDown();
sequence(function(){
html.classList.add('abemaTimetable');
$('#splash > div').animate([{opacity: '0'}, {opacity: '1'}], {duration: ABEMATIMETABLEDURATION, fill: 'forwards'});
}, ABEMATIMETABLEDURATION/*重たい処理に邪魔されず音量をなめらかに下げる猶予*/, function(){
core.panel.toggle('timetablePanel', core.timetable.createPanel);
elements.timetablePanel.querySelector('button.ok').addEventListener('click', core.abemaTimetable.closeOnAbemaTimetable);
}, 2500/*映像も隠し音量も下げたので、重たい公式番組表ページに移動するのは落ち着いたあとでよい*/, function(){
elements.abemaTimetableButton.click()
});
}
},
volumeDown: function(){
/* 音量ダウンの耳心地ベストを検証した末のeaseout */
let media = Array.from([...$$('video[src]'), ...$$('audio[src]')]), step = 10, begin = Date.now();
let easeoutDown = (now, original) => original * Math.pow(1 - Math.min(((now - begin) / ABEMATIMETABLEDURATION), 1), 2);/* (1-X)^2 */
if(!media.length) return;
/* 元音量 */
for(let i = 0; media[i]; i++){
media[i].originalVolume = media[i].volume;
}
/* 音量ダウンタイマーを一気に設置(intervalに比べてタイミングが乱れにくい) */
for(let s = 1; s <= step; s++){
setTimeout(function(){
for(let i = 0; media[i]; i++){
if(s === step) media[i].volume = 0;
else media[i].volume = easeoutDown(Date.now(), media[i].originalVolume);
}
}, ABEMATIMETABLEDURATION * (s/step));
}
},
goChannel: function(id){
/* 目的チャンネルで現在放送中の番組を番組表の中から探す */
let button = site.get.abemaTimetableSlotButton(id, core.getProgramIdNowOnAir(id));
if(!button) return location.assign('/now-on-air/' + id);
/* クリックして放送ページへのリンクを出現させる */
button.click();
animate(function(){
let a = site.get.abemaTimetableNowOnAirLink(id);
if(!a) return location.assign('/now-on-air/' + id);
/* ついに念願のチャンネル切り替えイベントを流用できるa要素を手に入れた */
a.click();
/* 放送中のチャンネルに移動するときは番組表を閉じる */
sequence(1000/*ページ遷移に時間がかかるので慌てて番組表を閉じずに*/, function(){
$('#splash > div').animate([{opacity: '1'}, {opacity: '0'}], {duration: ABEMATIMETABLEDURATION, fill: 'forwards'});
core.panel.toggle('timetablePanel', core.timetable.createPanel);
}, ABEMATIMETABLEDURATION, function(){
html.classList.remove('abemaTimetable');
});
});
},
},
timetable: {
createButton: function(){
let button = elements.channelButton.cloneNode(true);
button.dataset.selector = SCRIPTNAME + '-button';
button.title = SCRIPTNAME + ' 番組表';
button.setAttribute('aria-label', SCRIPTNAME);
button.appendChild(button.firstElementChild.cloneNode(true));
button.addEventListener('click', function(e){
core.panel.toggle('timetablePanel', core.timetable.createPanel);
}, true);
elements.channelButton.parentNode.insertBefore(button, elements.channelButton.nextElementSibling)
},
createPanel: function(){
let timetablePanel = elements.timetablePanel = createElement(core.html.timetablePanel());
timetablePanel.querySelector('button.ok').addEventListener('click', core.panel.close.bind(null, 'timetablePanel'));
core.timetable.buildTimes();
core.timetable.buildDays();/*先に作ったtimesのdisable判定を含む*/
core.timetable.buildTimetable();
core.timetable.searchPane.build();
core.timetable.searchPane.buildNotifications();
core.config.createButton();
core.panel.open('timetablePanel');
core.timetable.listenSelection();
core.timetable.listenMousewheel();
//core.timetable.listenMousemove();
core.timetable.shiftTimetable();
core.timetable.useChannelPane();
setTimeout(core.timetable.setupScrolls, 1000);
},
buildDays: function(){
if(!elements.timetablePanel) return;
let now = new Date(), starts = [], today = MinuteStamp.justToday();
let getDay = (d) => EDAYS[d.getDay()];
let formatDate = (d) => `${d.getMonth() + 1}月${d.getDate()}日(${JDAYS[d.getDay()]})`;
let formatDay = (d) => `${d.getDate()}${JDAYS[d.getDay()]}`;
let disableTimes = function(){
let past = MinuteStamp.past();
let inputs = elements.timetablePanel.querySelectorAll('nav .times input:not(.template)');
for(let i = 0; inputs[i]; i++){
if(parseInt(inputs[i].value*HOUR) < past) inputs[i].disabled = true;
}
};
for(let t = 0, y = now.getFullYear(), m = now.getMonth(), d = now.getDate(); t <= TERM; t++) starts.push(new Date(y, m, d + t));
let days = elements.days = elements.timetablePanel.querySelector('nav .days');
let templates = {input: days.querySelector('input.template'), label: days.querySelector('label.template')};
while(days.children.length > 2/*template*2*/) days.removeChild(days.children[0]);
for(let i = 0; starts[i]; i++){
let time = parseInt(starts[i].getTime() / 1000);
let input = templates.input.cloneNode(true);
let label = templates.label.cloneNode(true);
input.classList.remove('template');
label.classList.remove('template');
input.id = 'day-' + time;
input.value = time;
input.checked = (i === 0);
input.addEventListener('click', function(e){
let past = MinuteStamp.past();
let checked = elements.timetablePanel.querySelector('nav .times input:checked');
let start = time;
let delta = (!checked) ? past : (checked.value*HOUR);
core.timetable.buildTimetable(start + delta);
core.timetable.scrollTo(start + delta);
});
input.addEventListener('change', function(e){
if(i === 0) return disableTimes();
elements.timetablePanel.querySelectorAll('nav .times input:disabled').forEach((input) => input.disabled = false);
});
label.setAttribute('for', input.id);
label.classList.add(getDay(starts[i]));
label.firstElementChild.textContent = (i === 0) ? formatDate(starts[i]) : formatDay(starts[i]);
days.insertBefore(input, templates.input);
days.insertBefore(label, templates.input);
}
disableTimes();/*初期化*/
},
buildTimes: function(){
if(!elements.timetablePanel) return;
let deltas = TIMES, past = MinuteStamp.past(), today = MinuteStamp.justToday();
let times = elements.times = elements.timetablePanel.querySelector('nav .times');
let templates = {input: times.querySelector('input.template'), label: times.querySelector('label.template')};
while(times.children.length > 2/*template*2*/) times.removeChild(times.children[0]);
for(let i = 0; deltas[i] !== undefined/*0も入ってるので*/; i++){
let input = templates.input.cloneNode(true);
let label = templates.label.cloneNode(true);
input.classList.remove('template');
label.classList.remove('template');
input.id = 'time-' + deltas[i];
input.value = deltas[i];
if((past+JST)/HOUR < deltas[i]) input.checked = true;
input.addEventListener('click', function(e){
let checked = elements.timetablePanel.querySelector('nav .days input:checked');
let start = (checked.value === 'now') ? today : parseInt(checked.value);
let delta = deltas[i]*HOUR;
core.timetable.buildTimetable(start + delta);
core.timetable.scrollTo(start + delta);
});
label.setAttribute('for', input.id);
label.firstElementChild.textContent = deltas[i] + ':00';
times.insertBefore(input, templates.input);
times.insertBefore(label, templates.input);
}
},
setupScrolls: function(){
if(!elements.timetablePanel) return;
let channelsUl = elements.channelsUl, scrollers = elements.scrollers = elements.timetablePanel.querySelector('.scrollers');
let nowButton = elements.timetablePanel.querySelector('button.now');
/* スクロールボタン */
let left = elements.scrollerLeft = scrollers.querySelector('.left'), right = elements.scrollerRight = scrollers.querySelector('.right');
let searchPane = elements.searchPane;
right.addEventListener('click', function(e){
if(searchPane.classList.contains('active')) return searchPane.classList.remove('active');/*検索ペインを閉じるボタンとして機能させる*/
core.timetable.scrollTo(channelsUl.scrollTime + HOUR);
});
left.addEventListener('click', function(e){
core.timetable.scrollTo(channelsUl.scrollTime - HOUR);
});
right.classList.remove('disabled');
/* スクロール先の時間帯で番組表示 */
channelsUl.addEventListener('scroll', function(e){
if(channelsUl.scrolling) return;
channelsUl.scrolling = true;
setTimeout(function(){
let now = MinuteStamp.now(), past = MinuteStamp.past(), range = (TERM + 1)*DAY - past;
let start = now + ((channelsUl.scrollLeft / channelsUl.scrollWidth) * range);
core.timetable.buildTimetable(start);
channelsUl.scrolling = false;
/* バウンシングエフェクト */
let scrollLeftMax = channelsUl.scrollWidth - channelsUl.clientWidth;
if(channelsUl.scrollLeft === 0) channelsUl.scrollLeft = BOUNCINGPIXEL;
else if(channelsUl.scrollLeft === scrollLeftMax) channelsUl.scrollLeft = scrollLeftMax - BOUNCINGPIXEL;
/* Days/Timesの切り替え */
let days = elements.timetablePanel.querySelectorAll('nav .days input:not(.template)');
for(let i = 1; days[i]; i++){
if(start < parseInt(days[i].value)){
days[i - 1].checked = true;
days[i - 1].dispatchEvent(new Event('change'));
break;
}else if(i === days.length - 1){
days[i].checked = true;
days[i].dispatchEvent(new Event('change'));
}
}
let times = elements.timetablePanel.querySelectorAll('nav .times input:not(.template)');
for(let i = 1; times[i]; i++){
if(((start + MINUTE/*1分タイマーシフトのズレをカバー*/ + JST) % DAY) / HOUR < parseInt(times[i].value)){
times[i - 1].checked = true;
break;
}else if(i === times.length - 1){
times[i].checked = true;
}
}
/* 現在時刻に戻るボタン */
if(channelsUl.scrollLeft <= BOUNCINGPIXEL) nowButton.classList.add('disabled');
else if(nowButton.classList.contains('disabled')) nowButton.classList.remove('disabled');
/* スクロールボタンの切り替え */
if(channelsUl.scrollLeft <= BOUNCINGPIXEL) left.classList.add('disabled');
else if(left.classList.contains('disabled')) left.classList.remove('disabled');
if(channelsUl.scrollLeft === scrollLeftMax - BOUNCINGPIXEL) right.classList.add('disabled');
else if(right.classList.contains('disabled')) right.classList.remove('disabled');
}, 100);
}, {passive: true});/*Passive Event Listener*/
},
buildTimetable: function(start = MinuteStamp.now()){
let now = MinuteStamp.now(), past = MinuteStamp.past(), range = (configs.span*HOUR), ratio = (100 - NAMEWIDTH) / (configs.span*HOUR);
let fullwidth = (((TERM + 1)*DAY - past) / range) * (100 - NAMEWIDTH) + 'vw';
let timetablePanel = elements.timetablePanel, channelsUl = elements.channelsUl = timetablePanel.querySelector('.channels');
let show = function(element){
element.classList.remove('hidden');
element.addEventListener('transitionend', function(e){
element.classList.remove('animate');
}, {once: true});
};
channelsUl.scrollTime = start;/*スクロール用に保持しておく*/
/* 時間帯(目盛りになるので一括して全部作る) */
let timeLi = channelsUl.querySelector('.channels > .time'), timesUl = timeLi.querySelector('.times');
timeLi.style.width = fullwidth;
if(timesUl.children.length === 2/*templates*/){
/* 現在時刻に戻るボタン */
let button = timeLi.querySelector('button.now');
button.addEventListener('click', function(e){
core.timetable.scrollTo(MinuteStamp.now());
});
/* 時と日を生成 */
let ht = timesUl.querySelector('.hour.template'), dt = timesUl.querySelector('.day.template');
for(let hour = now - (start - range)%HOUR; hour < now - past + (TERM+1)*DAY; hour += HOUR){
/* 時を作成 */
let hourLi = ht.cloneNode(true);
hourLi.classList.remove('template');
hourLi.startAt = hour;
hourLi.endAt = hour + HOUR;
hourLi.duration = HOUR;
hourLi.style.left = Math.max((hour - now) * ratio, 0) + 'vw';
hourLi.style.width = ((hour < now) ? HOUR - (now%HOUR) : HOUR) * ratio + 'vw';
hourLi.addEventListener('click', function(e){
core.timetable.scrollTo(hour);
});
let oclock = (((hour+JST)%DAY)/HOUR);
if(hour < now){
hourLi.classList.add('nowonair');
hourLi.querySelector('.time').appendChild(MinuteStamp.timeToClock(now));
}else{
hourLi.querySelector('.time').textContent = oclock + ':00';
}
timesUl.insertBefore(hourLi, ht);
/* 日を作成 */
if(hour < now || oclock === 0){
let dayLi = dt.cloneNode(true);
dayLi.classList.remove('template');
dayLi.startAt = (hour < now) ? (now - past) : hour;
dayLi.endAt = (hour < now) ? (now - past) + DAY : (hour + DAY);
dayLi.duration = DAY;
dayLi.style.left = Math.max((hour - now) * ratio, 0) + 'vw';
dayLi.style.width = (((hour < now) ? (DAY - past) : DAY) * ratio) + 'vw';
dayLi.querySelector('.date').textContent = MinuteStamp.minimumDateToString(hour);
timesUl.insertBefore(dayLi, hourLi);
}
}
animate(show.bind(null, timeLi));
}
/* スワイプによるブラウザバックを防ぐためにバウンシングエフェクトを作る */
if(start === now) animate(() => channelsUl.scrollLeft = BOUNCINGPIXEL);
/* 各チャンネル */
for(let c = 0, delay = 0, channel; channel = channels[c]; c++){
if(!configs.c_visibles[channel.id]) continue;
let channelLi = document.getElementById('channel-' + channel.id), current = location.href.endsWith('/now-on-air/' + channel.id);
if(!channelLi){
channelLi = channelsUl.querySelector('.channel.template').cloneNode(true);
channelLi.classList.remove('template');
channelLi.id = 'channel-' + channel.id;
if(current) channelLi.classList.add('current');
channelLi.querySelector('.name').textContent = channel.name;
channelLi.querySelector('header').addEventListener('click', function(e){
core.timetable.showProgramData(core.getProgramById(core.getProgramIdNowOnAir(channel.id)));/*移り変わるのでつど取得*/
animate(function(){core.goChannel(channel.id)});
});
channelsUl.insertBefore(channelLi, channelsUl.lastElementChild);
}
channelLi.style.width = fullwidth;
let programsUl = channelLi.querySelector('.programs');
clearTimeout(channelLi.timer), channelLi.timer = setTimeout(function(){/*非同期処理にする*/
/* 表示済みの番組要素の再利用と削除 */
let programLis = programsUl.querySelectorAll('.program:not(.template)');/*nowonairや最初の一画面だけ残す手もるけどshiftTimetableが複雑化するので保留*/
for(let i = 0, li; li = programLis[i]; i++){
if(li.endAt <= start - range/2 || start + range + range < li.startAt) programsUl.removeChild(li);
}
/* 各番組 */
for(let p = 0, program; program = channel.programs[p]; p++){
if(document.getElementById('program-' + program.id)) continue;/*表示済み*/
if(program.endAt <= now) continue;/*現在より過去*/
if(program.endAt <= start - range/2) continue;/*表示範囲より過去*/
if(start + range + range <= program.startAt) break;/*表示範囲より未来(以降は処理不要)*/
/* programLiを作成 */
let programLi = programsUl.querySelector('.program.template').cloneNode(true);
programLi.classList.remove('template');
programLi.id = 'program-' + program.id;
programLi.dataset.once = program.id;
if(program.repeat) programLi.dataset.repeat = program.repeat;
if(program.noContent) programLi.classList.add('nocontent');
let time = programLi.querySelector('.time'), title = programLi.querySelector('.title');
/* 時刻と通知ボタン */
time.textContent = program.startAtString;
programLi.insertBefore(Notifier.createButton(program), time);
/* タイトル */
title.textContent = program.title || NOCONTENTS[0];
if(program.title === NOCONTENTS[0]){/*空き枠*/
programLi.classList.add('padding');
}else{
if(Notifier.match(program.id)) programLi.classList.add('active');
Program.appendMarks(title, program.marks);
programLi.addEventListener('click', function(e){
/* 2度目のクリック時のみ番組開始時刻にスクロールさせる */
if(elements.programDiv && elements.programDiv.programData.id === program.id){/*shownクラスがなぜか判定に使えないので*/
core.timetable.scrollTo(program.startAt);
}
core.timetable.showProgramData(program);
});
}
/* 番組の幅を決める */
programLi.startAt = program.startAt;
programLi.endAt = program.endAt;
programLi.duration = program.duration;
if(program.startAt <= now){/*現在放送中*/
programLi.classList.add('nowonair');
programLi.style.left = '0vw';
programLi.style.width = (program.duration - (now - program.startAt)) * ratio + 'vw';
if(program.title === NOCONTENTS[0]) channelLi.classList.add('notonair');
/* 番組情報が空欄なら現在視聴中の番組情報を表示 */
if(current && timetablePanel.isConnected && timetablePanel.querySelector('.panel > .program.nocontent')) core.timetable.showProgramData(program);
}else{/*後続番組*/
programLi.style.left = (program.startAt - now) * ratio + 'vw';
programLi.style.width = (program.duration) * ratio + 'vw';
}
programsUl.insertBefore(programLi, programsUl.lastElementChild);
animate(function(){programLi.classList.remove('hidden')});
}
/* 初回アニメーション */
if(channelLi.classList.contains('hidden')) animate(show.bind(null, channelLi));
/* 最後に1度だけ */
if(c === channels.length - 1 && timetablePanel){
core.timetable.fitWidth();
let program = timetablePanel.querySelector('.panel > .program').programData;
if(program) core.timetable.highlightProgram(program);
}
}, (delay++) * (1000/60));
}
},
rebuildTimetable: function(){
if(!elements.timetablePanel || !elements.timetablePanel.isConnected) return;
let channelsUl = elements.channelsUl = elements.timetablePanel.querySelector('.channels');
for(let i = 0, channelLis = channelsUl.querySelectorAll('.channel:not(.template)'); channelLis[i]; i++){
channelLis[i].parentNode.removeChild(channelLis[i]);
}
core.timetable.buildTimetable(channelsUl.scrollTime);
},
scrollTo: function(start){
let past = MinuteStamp.past(), range = (TERM + 1)*DAY - past, today = MinuteStamp.justToday(), ratio = (start - (today + past)) / range;
let channelsUl = elements.channelsUl, scrollLeftMax = channelsUl.scrollWidth - channelsUl.clientWidth;
let to = Math.max(0, Math.min(channelsUl.scrollWidth * ratio, scrollLeftMax)), gap = to - channelsUl.scrollLeft;
if(gap === 0) return;
channelsUl.scrollTime = start;
let streams = channelsUl.querySelectorAll('li:not(.template) > .stream'), count = 0;
for(let i = 0; streams[i]; i++){
streams[i].style.willChange = 'transform';
streams[i].style.transition = 'transform 1s ease';
}
animate(function(){
for(let i = 0; streams[i]; i++){
streams[i].style.transform = `translateX(${-gap}px)`;
}
});
streams[streams.length - 1].addEventListener('transitionend', function(e){/*疑似スクロールを破綻させないようにタイミングを一致させる*/
for(let i = 0; streams[i]; i++){
streams[i].style.willChange = '';
streams[i].style.transition = 'none';/*scrollLeftを即反映させる*/
streams[i].style.transform = '';
}
channelsUl.scrollLeft = Math.ceil(to)/*borderズレを常に回避する*/;
}, {once: true});
},
shiftTimetable: function(){
// animateをひとつにするべきなのかもしれないけど
let channelsUl = elements.channelsUl;
if(!channelsUl || !channelsUl.isConnected) return;
if(document.hidden){
if(document.shiftTimetable) return;/*重複防止*/
document.shiftTimetable = true;
document.addEventListener('visibilitychange', function(){
document.shiftTimetable = false;
core.timetable.shiftTimetable();
}, {once: true});
return;
}
const change = function(element, left, width, callback){
if(channelsUl.scrollLeft <= BOUNCINGPIXEL && left < 100){
element.style.willChange = 'left';
element.style.transition = 'left 1000ms ease, width 1000ms ease, background 1000ms ease, filter 1000ms ease, padding-left 500ms ease 1000ms';
animate(function(){
element.style.left = left + 'vw';
if(left === 0) element.style.width = width + 'vw';
element.addEventListener('transitionend', function(e){
element.style.willChange = '';
element.style.transition = '';
if(callback) callback();
}, {once: true});
});
}else{
element.style.left = left + 'vw';
if(left === 0) element.style.width = width + 'vw';
if(callback) callback();
}
};
let now = MinuteStamp.now(), past = MinuteStamp.past(), end = now + (configs.span*HOUR), ratio = (100 - NAMEWIDTH) / (configs.span*HOUR);
/* 各チャンネル */
let channelLis = channelsUl.querySelectorAll('.channels > li:not(.template)');
let oldWidth = channelLis[0].scrollWidth, newlWidthVW = (((TERM + 1)*DAY - past) / (configs.span*HOUR)) * (100 - NAMEWIDTH);
for(let c = 0, channelLi; channelLi = channelLis[c]; c++){
channelLi.style.width = newlWidthVW + 'vw';/*チャンネル自体の幅を狭める*/
/* 各番組 */
let slots = channelLi.querySelectorAll('.slot:not(.template)');
for(let s = 0, slotLi; slotLi = slots[s]; s++){
let startAt = slotLi.startAt, endAt = slotLi.endAt, duration = slotLi.duration;
switch(true){
case(endAt <= now):/*放送終了*/
change(slotLi, 0, 0, function(e){
if(slots[s + 1]){/*必ずしも隣じゃないけどレアケースなので*/
Slot.highlight(slots[s + 1], 'add', 'nowonair');
if(slotLi.classList.contains('shown')) slots[s + 1].click();
}
if(slotLi.isConnected) Slot.highlight(slotLi, 'remove', 'nowonair'), slotLi.parentNode.removeChild(slotLi);
if(channelLi.classList.contains('notonair')) channelLi.classList.remove('notonair');
});
break;
case(startAt <= now):/*現在放送中*/
change(slotLi, 0, (duration - (now - startAt)) * ratio);
/* 現在時刻更新 */
if(slotLi.classList.contains('hour')){
let time = slotLi.querySelector('.time');
time.replaceChild(MinuteStamp.timeToClock(now), time.firstChild);
}
break;
case(startAt < end):/*後続番組*/
default:/*画面外*/
change(slotLi, (startAt - now) * ratio, duration * ratio);
break;
}
}
}
/* 短くなったぶんだけスクロールする */
if(oldWidth < channelLis[0].scrollWidth) oldWidth += ((DAY * ratio) * window.innerWidth) /100;/*日付が変わったときだけは1日分長くなるので*/
channelsUl.scrollLeft = Math.max(channelsUl.scrollLeft - (oldWidth - channelLis[0].scrollWidth), BOUNCINGPIXEL);
/* 現在時刻にいるときは長時間放置で後続番組がなくならないように再構築させる */
if(channelsUl.scrollLeft === BOUNCINGPIXEL) setTimeout(function(){core.timetable.buildTimetable(channelsUl.scrollTime = now)}, 1000);
},
showProgramData: function(program){
/* timetable */
let shown = elements.timetablePanel.querySelector('.channels .shown'), show = document.getElementById('program-' + program.id);
if(shown) shown.classList.remove('shown');
if(show) show.classList.add('shown');
/* programDiv */
let programDiv = elements.programDiv = elements.timetablePanel.querySelector('.panel > .program');
programDiv.scrollTop = 0;
if(programDiv.classList.contains('nocontent')) programDiv.classList.remove('nocontent');
else programDiv.animate([{opacity: 0}, {opacity: 1}], {duration: 250, easing: 'ease-out'});
programDiv.programData = program;/*番組表をハイライトするタイミングで活用*/
/* title */
let title = programDiv.querySelector('.title');
title.textContent = program.title;
Array.from(title.parentNode.children).forEach((node) => {
if(node !== title) title.parentNode.removeChild(node);/*マークをいったん取り除く*/
});
Program.appendMarks(title, program.marks);
/* thumbnails */
let thumbnailsDiv = programDiv.querySelector('.thumbnails');
while(thumbnailsDiv.children.length) thumbnailsDiv.removeChild(thumbnailsDiv.children[0]);
if(program.thumbImg){
thumbnailsDiv.appendChild(new Thumbnail(program.displayProgramId, program.thumbImg, 'large').node);
}
for(let i = 0; program.sceneThumbImgs[i]; i++){
thumbnailsDiv.appendChild(new Thumbnail(program.displayProgramId, program.sceneThumbImgs[i]).node);
}
/* summary */
let summaryDiv = programDiv.querySelector('.summary');
summaryDiv.querySelector('.channel').textContent = program.channel.name;
let dateP = summaryDiv.querySelector('.date');
dateP.querySelector('span').textContent = program.dateString;
summaryDiv.querySelector('.highlight').textContent = program.detailHighlight;
/* links */
let linksUl = summaryDiv.querySelector('.links');
while(linksUl.children.length > 1/*template*/) linksUl.removeChild(linksUl.firstElementChild);
if(program.links){
linksUl.classList.remove('inactive');
let templateLi = linksUl.querySelector('.template');
for(let i = 0; program.links[i]; i++){
let li = templateLi.cloneNode(true), a = li.querySelector('a');
li.classList.remove('template');
a.href = program.links[i].value;
a.textContent = program.links[i].title;
linksUl.insertBefore(li, templateLi);
}
}else{
linksUl.classList.add('inactive');
}
/* myvideo */
let timeshiftP = summaryDiv.querySelector('.timeshift');
if(program.timeshiftString !== ''){
timeshiftP.classList.remove('inactive');
timeshiftP.querySelector('span').textContent = program.timeshiftString;
while(timeshiftP.children.length > 1/*template*/) timeshiftP.removeChild(timeshiftP.firstElementChild);
let myvideoButton = MyVideo.createMyvideoButton(program);
timeshiftP.insertBefore(myvideoButton, timeshiftP.firstElementChild);
}else{
timeshiftP.classList.add('inactive');
}
/* group and series */
let now = MinuteStamp.now(), results = [], count = {};
['group', 'series'].forEach((key, i) => {
/* 一致program取得 */
for(let c = 0; channels[c]; c++){
for(let p = 0; channels[c].programs[p]; p++){
if(channels[c].programs[p].endAt < now) continue;/*終了した番組は表示しない*/
if(channels[c].programs[p][key] && channels[c].programs[p][key] === program[key]){
if(1 <= i && results.some((result) => result.id === channels[c].programs[p].id)) continue;/*重複させない*/
results.push(channels[c].programs[p]);
count[key] = count[key] + 1 || 1;
}
}
}
if(results.length === 1) results.pop(results[0]);/*自分自身の番組しかなければ取り除く*/
else if(1 <= i && !count[key]) while(results.length) results.pop(results[0]);/*同じ内容なら繰り返さない*/
results.sort((a, b) => a.startAt - b.startAt);/*日付順*/
/* タイトルの重複文字列を省略する準備 */
let shorten;
if(results.every((r) => r.title === program.title)){
shorten = () => '同';
}else{
/* 区切り文字は/(?=\s)/とし、全タイトル共通文字列と、半数以上のタイトルに共通する文字列を削除する */
let parts = program.title.split(/(?=\s)/), former = {all: '', majority: ''}, latter = {all: '', majority: ''}, n = '\n';
/* 前方一致部分文字列 */
for(let i = 0; parts[i]; i++) if(results.every((r) => r.title.startsWith(former.all + parts[i]))) former.all += parts[i];
for(let i = 0; parts[i]; i++) if(results.filter((r) => r.title.startsWith(former.majority + parts[i])).length >= results.length/2) former.majority += parts[i];
/* 後方一致部分文字列 */
for(let i = parts.length - 1; parts[i]; i--) if(results.every((r) => r.title.endsWith(parts[i] + latter.all))) latter.all = parts[i] + latter.all;
for(let i = parts.length - 1; parts[i]; i--) if(results.filter((r) => r.title.endsWith(parts[i] + latter.majority)).length >= results.length/2) latter.majority = parts[i] + latter.majority;
/* 削りすぎを回避する */
if((former.majority + latter.majority).length >= program.title.length) former.majority = '';
if((former.majority + latter.majority).length >= program.title.length) latter.majority = '';
shorten = (title) => (title + n).replace(former.majority, '').replace(former.all, '').replace(latter.majority + n, n).replace(latter.all + n, n).trim();
}
/* 放送予定リストDOM構築 */
let div = summaryDiv.querySelector(`.${key}`), ul = div.querySelector(`.${key} ul`), templateLi = ul.querySelector('.template');
if(1 <= i && !results.length) div.classList.add('inactive');
else div.classList.remove('inactive');
while(ul.children.length > 1/*template*/) ul.removeChild(ul.children[0]);
if(results.length === 0){
let li = templateLi.cloneNode(true);
li.classList.remove('template');
li.textContent = '-';
ul.insertBefore(li, templateLi);
}else{
for(let p = 0, result; result = results[p]; p++){
let li = templateLi.cloneNode(true), header = li.querySelector('header');
li.classList.remove('template');
if(program.id === result.id) li.classList.add('current');
else header.addEventListener('click', function(e){
core.timetable.showProgramData(result);
core.timetable.scrollTo(result.startAt);
});
header.insertBefore(Notifier.createButton(result), header.firstElementChild);
li.querySelector('.date').textContent = result.justifiedStartAtShortDateString;
let title = li.querySelector('.title');
title.textContent = shorten(result.title);
Program.appendMarks(title, result.marks);
ul.insertBefore(li, templateLi);
}
}
});
/* 1回通知 */
while(dateP.children.length > 1) dateP.removeChild(dateP.firstElementChild);
let button = Notifier.createButton(program);
dateP.insertBefore(button, dateP.firstElementChild);
/* 毎回通知 */
let h3 = summaryDiv.querySelector('h3');
while(h3.children.length > 1) h3.removeChild(h3.firstElementChild);
if(program.repeat){
let repeatButton = Notifier.createRepeatAllButton(program);
h3.insertBefore(repeatButton, h3.firstElementChild);
}
/* content */
let content = programDiv.querySelector('.content div'), paragraphs = program.content.split(/\n+/);
while(content.children.length) content.removeChild(content.children[0]);
for(let i = 0; paragraphs[i]; i++){
let p = document.createElement('p');
p.textContent = paragraphs[i];
linkify(p);
content.appendChild(p);
}
/* casts and crews */
let searchInput = elements.timetablePanel.querySelector('nav > .search input');
['casts', 'crews'].forEach((key) => {
let ul = programDiv.querySelector(`.${key} ul`);
while(ul.children.length) ul.removeChild(ul.children[0]);
for(let i = 0; program[key][i]; i++){
let li = document.createElement('li');
li.textContent = program[key][i];
Program.linkifyNames(li, function(e){
core.timetable.searchPane.search(e.target.textContent);
});
ul.appendChild(li);
}
if(ul.children.length === 0){
let li = document.createElement('li');
li.textContent = '-';
ul.appendChild(li);
}
});
/* copyrights */
programDiv.querySelector('.copyrights').textContent = program.copyrights.join(', ');
/* highlight */
core.timetable.highlightProgram(program);
},
highlightProgram: function(program){
let oldShown = elements.channelsUl.querySelector('.program.shown');
if(oldShown) Slot.highlight(oldShown, 'remove', 'shown');
let newShown = document.getElementById('program-' + program.id);
if(newShown) Slot.highlight(newShown, 'add', 'shown');
},
listenSelection: function(){
let programDiv = elements.timetablePanel.querySelector('.panel > .program');
let select = function(e){
let selection = window.getSelection(), selected = selection.toString();
if(selection.isCollapsed) return;
if(0 <= selected.indexOf('\n')) return;
let value = selected.trim();
if(value === '') return;
core.timetable.searchPane.search(value);
};
programDiv.addEventListener('mousedown', function(e){
programDiv.addEventListener('mouseup', function(e){
animate(function(){select(e)});/*ダブルクリックでのテキスト選択をanimateで確実に補足*/
}, {once: true});
});
},
listenMousewheel: function(){
let channelsUl = elements.channelsUl;
channelsUl.addEventListener('wheel', function(e){
if(Math.abs(e.deltaY) < 1) return;
if(1 < Math.abs(e.deltaX)) return;
if(e.target.localName === 'h2') return;
channelsUl.scrollLeft += e.deltaY;
}, {passive: true});
},
listenMousemove: function(){
let timetablePanel = elements.timetablePanel, classList = timetablePanel.classList;
let id, timer = function(e){
if(!classList.contains('active')) classList.add('active');
clearTimeout(id), id = setTimeout(function(){
classList.remove('active');
}, 1000);
};
timetablePanel.addEventListener('mousemove', timer);
},
useChannelPane: function(){
/* ChannelPaneのチャンネル切り替えイベントを流用できるようにしておく */
if(location.href.includes('/now-on-air/')){
if(!site.get.screenCommentScroller()) return;/*既に開いてくれているはず*/
core.channelPane.openHide();
core.timetable.addCloseListener('channelPaneHidden', function(){
elements.closer.click();
html.classList.remove('channelPaneHidden');
});
}
},
addCloseListener: function(name, listener){
elements.timetablePanel.querySelector('button.ok').addEventListener('click', listener);
if(!elements.panels['listening-' + name]){
elements.panels['listening-' + name] = true;
window.addEventListener('keypress', function(e){
if(['input', 'textarea'].includes(document.activeElement.localName)) return;
if(elements.timetablePanel && e.key === 'Escape') return listener();
});
}
},
fitWidth: function(){
if(!elements.timetablePanel || !elements.timetablePanel.isConnected) return;
let timetablePanel = elements.timetablePanel, fits = timetablePanel.querySelectorAll('.fit');
for(let i = 0; fits[i]; i++){
if(fits[i].scrollWidth < fits[i].clientWidth) fits[i].style.transform = '';
else fits[i].style.transform = `scaleX(${fits[i].clientWidth / fits[i].scrollWidth})`;
}
},
searchPane: {
build: function(){
let searchInput = elements.searchInput = elements.timetablePanel.querySelector('nav > .search input[type="search"]');
let searchButton = elements.searchButton = elements.timetablePanel.querySelector('nav > .search button.search');
let searchPane = elements.searchPane = elements.timetablePanel.querySelector('.programs > .search');
/* 検索 */
searchInput.addEventListener('keypress', function(e){
if(e.key === 'Escape') return searchPane.classList.remove('active');
if(e.key !== 'Enter') return;
let value = searchInput.value.trim();
if(value === ''){
searchPane.classList.remove('active');
searchPane.dataset.mode = '';
return;
}
core.timetable.searchPane.search(value);/*marks絞りなしで一括取得*/
});
searchButton.addEventListener('click', function(e){
let value = searchInput.value.trim();
if(value === ''){
searchPane.classList.remove('active');
searchPane.dataset.mode = '';
return;
}
core.timetable.searchPane.search(value);/*marks絞りなしで一括取得*/
});
},
search: function(value, marks = site.marks){
let searchInput = elements.searchInput, searchPane = elements.searchPane, ul = searchPane.querySelector('ul');
value = value.trim();
searchInput.value = value;
ul.results = core.searchPrograms(value);/*全件取得してDOMプロパティに渡しておく(絞り込みはlistSearchResultsでinputを判定して行う)*/
core.timetable.searchPane.buildSearchHeader();
core.timetable.searchPane.updateSearchFillters(value, marks);
core.timetable.searchPane.listSearchResults(value);
searchPane.classList.add('active');
searchPane.dataset.mode = 'search';
},
buildSearchHeader: function(){
let searchInput = elements.searchInput, searchPane = elements.searchPane;
while(searchPane.children.length > 1/*ul*/) searchPane.removeChild(searchPane.children[0]);
searchPane.insertBefore(createElement(core.html.searchHeader()), searchPane.firstElementChild);
/* 絞り込みDOM生成 */
let filtersP = searchPane.querySelector('.filters');
let it = filtersP.querySelector('input.template'), lt = filtersP.querySelector('label.template');
site.marks.forEach(function(key){
let input = it.cloneNode(true);
let label = lt.cloneNode(true);
input.classList.remove('template');
label.classList.remove('template');
input.id = 'mark-' + key;
input.value = key;
label.setAttribute('for', 'mark-' + key);
label.appendChild(createElement(core.html.marks[key]()));
filtersP.insertBefore(input, it);
filtersP.insertBefore(label, it);
});
/* eventListener付与 */
let labels = filtersP.querySelectorAll('label:not(.template)');
labels.forEach((label) => {
/* 連続で次々クリックするとマウスポインタのズレによるclick判定漏れが起きやすいので、mousedownで処理する */
label.addEventListener('mousedown', function(e){
let input = label.previousElementSibling;
input.checked = !input.checked;
core.timetable.searchPane.listSearchResults(searchInput.value);
});
label.addEventListener('click', function(e){
e.preventDefault();
});
});
},
updateSearchFillters: function(value, marks){
let searchPane = elements.searchPane, labels = searchPane.querySelectorAll('.filters label:not(.template)');
labels.forEach((label) => {
let input = label.previousElementSibling;
input.checked = (marks.some((mark) => mark === input.value));
if(notifications.search[value] && notifications.search[value].includes(input.value)) label.classList.add('notify');
else label.classList.remove('notify');
});
},
listSearchResults: function(value){
let searchPane = elements.searchPane, summary = searchPane.querySelector('.summary');
let ul = searchPane.querySelector('ul');
/* 絞り込み */
let marks = [], filters = searchPane.querySelectorAll('.filters input:not(.template)');
filters.forEach((filter) => {if(filter.checked === true) marks.push(filter.value)});
let filteredResults = ul.results.filter((program) => {
if(marks.some((mark) => program.marks[mark] !== undefined)) return true;
if(marks.includes('none') && Object.keys(program.marks).length === 0) return true;
});
/* 検索結果リスト */
core.timetable.searchPane.listPrograms(ul, filteredResults);
core.timetable.searchPane.updateResultCount(filteredResults.length);
/* 検索結果を常に通知する */
while(summary.children.length > 1/*count*/) summary.removeChild(summary.lastElementChild);
summary.appendChild(Notifier.createSearchAllButton(value, marks));
},
buildNotifications: function(){
let searchInput = elements.searchInput, searchPane = elements.searchPane, ul = searchPane.querySelector('ul');
let button = elements.notificationsButton = elements.timetablePanel.querySelector('nav button.notifications');
Notifier.updateCount();
button.addEventListener('click', function(e){
if(searchPane.dataset.mode === 'notifications'){
searchPane.classList.remove('active');
searchPane.dataset.mode = '';
return;
}
searchPane.classList.add('active');
searchPane.dataset.mode = 'notifications';
searchInput.value = '';
ul.results = notifications.programs;/*DOMプロパティとして検索結果を渡す約束*/
core.timetable.searchPane.buildNotificationsHeader();
core.timetable.searchPane.listAllNotifications();
});
},
buildNotificationsHeader: function(){
let searchPane = elements.searchPane, header = searchPane.querySelector('header');
if(header) searchPane.removeChild(header);
searchPane.insertBefore(createElement(core.html.notificationsHeader()), searchPane.firstElementChild);
/* eventListener付与 */
let labels = searchPane.querySelectorAll('nav.tabs > label');
let actions = {
all: core.timetable.searchPane.listAllNotifications,
once: core.timetable.searchPane.listOnceNotifications,
repeat: core.timetable.searchPane.listRepeatNotifications,
search: core.timetable.searchPane.listSearchNotifications,
}
labels.forEach((label) => {
/* 連続で次々クリックするとマウスポインタのズレによるclick判定漏れが起きやすいので、mousedownで処理する */
label.addEventListener('mousedown', function(e){
let input = label.previousElementSibling;
input.checked = true;
actions[input.value]();
});
label.addEventListener('click', function(e){
e.preventDefault();
});
});
},
listAllNotifications: function(){
core.timetable.searchPane.listDaysNotifications(notifications.programs);
core.timetable.searchPane.updateResultCount(notifications.programs.length);
},
listOnceNotifications: function(){
let filteredResults = notifications.programs.filter((p) => {
if(!Notifier.matchOnce(p.once)) return false;
if(Notifier.matchRepeat(p.repeat)) return false;/*毎回通知は含めない*/
return true;
});
core.timetable.searchPane.listDaysNotifications(filteredResults);
core.timetable.searchPane.updateResultCount(filteredResults.length);
},
listDaysNotifications: function(programs){
let searchPane = elements.searchPane, ul = searchPane.querySelector('ul');
let labels = ['きょう', 'あした', 'あさって以降'], days = {}, today = MinuteStamp.justToday();
labels.forEach((label) => days[label] = []);
programs.forEach((p) => {
switch(true){
case(p.startAt < today + DAY*1): return days[labels[0]].push(p);
case(p.startAt < today + DAY*2): return days[labels[1]].push(p);
default: return days[labels[2]].push(p);
}
});
while(ul.children.length > 0) ul.removeChild(ul.children[0]);
labels.forEach((key) => {
let li = createElement(core.html.dayListItem()), h2 = li.querySelector('h2');
h2.textContent = key;
core.timetable.searchPane.listPrograms(li.querySelector('ul'), days[key]);
ul.appendChild(li);
});
},
listRepeatNotifications: function(){
let searchPane = elements.searchPane, ul = searchPane.querySelector('ul');
let repeats = {}, count = 0;
notifications.programs.forEach((p) => {
if(!Notifier.matchRepeat(p.repeat)) return;
if(MAXRESULTS <= count) return count++;
if(!repeats[p.repeat]) repeats[p.repeat] = [];
repeats[p.repeat].push(p);
count++;
});
Object.keys(notifications.repeat).forEach((key) => {
if(!repeats[key]) repeats[key] = [];
});
while(ul.children.length > 0) ul.removeChild(ul.children[0]);
Object.keys(repeats).forEach((key) => {
let li = createElement(core.html.repeatListItem()), h2 = li.querySelector('h2');
h2.querySelector('.title').textContent = notifications.repeat[key];
h2.insertBefore(Notifier.createRepeatAllButton(repeats[key][0] || {
/* 期間内に番組がなくても repeat, title さえあればボタン生成できる */
repeat: key,
title: notifications.repeat[key],
}), h2.firstChild);
core.timetable.searchPane.listPrograms(li.querySelector('ul'), repeats[key]);
ul.appendChild(li);
});
core.timetable.searchPane.updateResultCount(count);
},
listSearchNotifications: function(){
let searchPane = elements.searchPane, ul = searchPane.querySelector('ul');
let searches = {}, programs = [], limit = Infinity;
Object.keys(notifications.search).forEach((key) => {
searches[key] = core.searchPrograms(key, notifications.search[key]);
programs = programs.concat(searches[key]);
});
if(MAXRESULTS < programs.length) limit = programs[MAXRESULTS - 1].startAt;
while(ul.children.length > 0) ul.removeChild(ul.children[0]);
Object.keys(searches).sort((a, b) => {
if(searches[a][0] && searches[b][0]) return searches[a][0].startAt - searches[b][0].startAt;/*放送開始の早い順*/
else if(searches[a].length) return -1;/*検索に該当する番組がなければあとまわし*/
else if(searches[b].length) return +1;
else return b < a;/*該当する番組がないもの同士では辞書順*/
}).forEach((key) => {
let marks = notifications.search[key].map((name) => core.html.marks[name]());
let li = createElement(core.html.searchListItem(key, marks.join(''))), h2 = li.querySelector('h2');
h2.insertBefore(Notifier.createSearchButton(key), h2.firstChild);
core.timetable.searchPane.listPrograms(li.querySelector('ul'), searches[key].filter((p) => p.startAt <= limit));
ul.appendChild(li);
});
core.timetable.searchPane.updateResultCount(programs.length);
},
listPrograms: function(ul, programs){
let searchPane = elements.searchPane, summary = searchPane.querySelector('.summary');
/* 前準備 */
searchPane.scrollTop = 0;
[summary, ul].forEach((e) => e.animate([{opacity: 0}, {opacity: 1}], {duration: 250, easing: 'ease-out'}));
while(ul.children.length > 0) ul.removeChild(ul.children[0]);
if(programs.length === 0){
let li = createElement(core.html.noProgramListItem());
ul.appendChild(li);
}
for(let p = 0; programs[p] && p < MAXRESULTS; p++){
let li = createElement(core.html.programListItem());
let title = li.querySelector('.title');
title.textContent = programs[p].title;
Program.appendMarks(title, programs[p].marks);
let data = li.querySelector('.data');
data.insertBefore(Notifier.createButton(programs[p]), data.firstElementChild);
li.querySelector('.date').textContent = programs[p].justifiedDateString;
li.querySelector('.channel').textContent = programs[p].channel.name;
let thumbnail = li.querySelector('.thumbnail');
/* 遅延読み込み */
let observer = new IntersectionObserver(function(entries){
if(!entries[0].isIntersecting) return;
observer.disconnect();
thumbnail.appendChild(new Thumbnail(programs[p].displayProgramId, programs[p].thumbImg, 'large').node);
}, {root: searchPane, rootMargin: '50%'});
observer.observe(thumbnail);
li.addEventListener('click', function(e){
core.timetable.showProgramData(programs[p]);
core.timetable.scrollTo(programs[p].startAt);
});
ul.appendChild(li);
}
},
updateResultCount: function(length){
let searchPane = elements.searchPane, count = searchPane.querySelector('.count');
switch(true){
case(length === 0):
count.textContent = `見つかりませんでした`;
break;
case(length <= MAXRESULTS):
count.textContent = `${length}件見つかりました`;
break;
case(MAXRESULTS < length):
count.textContent = `${MAXRESULTS}件以上見つかりました`;
break;
}
},
},
},
getProgramById: function(id){
for(let c = 0, channel; channel = channels[c]; c++){
for(let p = 0, program; program = channel.programs[p]; p++){
if(program.id === id) return program;
}
}
},
matchProgram: function(program, value, marks = []){
if(program.noContent) return false;
let words = normalize(value.toLowerCase()).split(/\s+/);
if(!words.every((word) => {
return [
program.channel.name,
program.title,
...program.casts || [],
...program.crews || [],
].some((p) => (0 <= p.toLowerCase().indexOf(word)));
})) return false;
if(marks.length === 0) return true;
if(marks.some((mark) => program.marks[mark] !== undefined)) return true;
if(marks.includes('none') && Object.keys(program.marks).length === 0) return true;
},
searchPrograms: function(value, marks = []){
let now = MinuteStamp.now(), results = [];
for(let c = 0, channel; channel = channels[c]; c++){
for(let p = 0, program; program = channel.programs[p]; p++){
if(!configs.c_visibles[program.channel.id]) continue;
if(program.endAt <= now) continue;
if(program.noContent) continue;
if(core.matchProgram(program, value, marks)) results.push(program);
}
}
results.sort((a, b) => a.startAt - b.startAt);/*日付順*/
return results;
},
getProgramIdNowOnAir: function(channelId){
for(let now = MinuteStamp.now(), c = 0, channel; channel = channels[c]; c++){
if(channel.id !== channelId) continue;
for(let p = 0, program; program = channel.programs[p]; p++){
if(program.endAt < now) continue;
if(now < program.startAt) break;/*念のため*/
return program.id;
}
}
},
config: {
read: function(){
/* 保存済みの設定を読む */
configs = Storage.read('configs') || {};
/* 未定義項目をデフォルト値で上書きしていく */
Object.keys(CONFIGS).forEach((key) => {if(configs[key] === undefined) configs[key] = CONFIGS[key].DEFAULT});
},
save: function(new_config){
configs = {};/*CONFIGSに含まれた設定値のみ保存する*/
/* CONFIGSを元に文字列を型評価して値を格納していく */
Object.keys(CONFIGS).forEach((key) => {
/* 値がなければデフォルト値 */
if(new_config[key] === "") return configs[key] = CONFIGS[key].DEFAULT;
switch(CONFIGS[key].TYPE){
case 'bool':
configs[key] = (new_config[key]) ? 1 : 0;
break;
case 'int':
configs[key] = parseInt(new_config[key]);
break;
case 'float':
configs[key] = parseFloat(new_config[key]);
break;
default:
configs[key] = new_config[key];
break;
}
});
Storage.save('configs', configs);
},
createButton: function(){
elements.configButton = elements.timetablePanel.querySelector('button.config');
elements.configButton.addEventListener('click', core.panel.toggle.bind(null, 'configPanel', core.config.createPanel));
},
createPanel: function(){
elements.configPanel = createElement(core.html.configPanel());
let channelsUl = elements.configPanel.querySelector('.channels'), templateLi = channelsUl.querySelector('li.template');
for(let i = 0; channels[i]; i++){
let li = templateLi.cloneNode(true);
li.classList.remove('template');
let input = li.querySelector('input');
input.value = channels[i].id;
input.checked = configs.c_visibles[channels[i].id];
li.querySelector('label > span').textContent = channels[i].name;
channelsUl.insertBefore(li, templateLi);
}
channelsUl.removeChild(templateLi);
elements.configPanel.querySelector('button.cancel').addEventListener('click', core.panel.close.bind(null, 'configPanel'));
elements.configPanel.querySelector('button.save').addEventListener('click', function(){
let inputs = elements.configPanel.querySelectorAll('input'), new_configs = {};
for(let i = 0, input; input = inputs[i]; i++){
switch(CONFIGS[input.name].TYPE){
case('bool'):
new_configs[input.name] = (input.checked) ? 1 : 0;
break;
case('object'):
if(!new_configs[input.name]) new_configs[input.name] = {};
new_configs[input.name][input.value] = (input.checked) ? 1 : 0;
break;
default:
new_configs[input.name] = input.value;
break;
}
}
core.config.save(new_configs);
core.panel.close('configPanel')
/* 新しい設定値で再スタイリング */
core.addStyle();
core.channelPane.modify();
core.abemaTimetable.initialize();
core.timetable.rebuildTimetable();
}, true);
elements.configPanel.querySelector('input[name="n_change"]').addEventListener('click', function(e){
let n_overlap = elements.configPanel.querySelector('input[name="n_overlap"]');
n_overlap.disabled = !n_overlap.disabled;
n_overlap.parentNode.parentNode.classList.toggle('disabled');
}, true);
core.panel.open('configPanel');
},
},
panel: {
createPanels: function(){
if(elements.panels) return;
elements.panels = createElement(core.html.panels());
elements.panels.dataset.panels = 0;
document.body.appendChild(elements.panels);
},
open: function(key){
let target = null;
for(let i = PANELS.indexOf(key) + 1; PANELS[i] && !target; i++) if(elements[PANELS[i]]) target = elements[PANELS[i]];
elements[key].classList.add('hidden');
elements.panels.insertBefore(elements[key], target);
animate(function(){
elements.panels.dataset.panels = parseInt(elements.panels.children.length);
elements[key].classList.remove('hidden');
});
elements.panels.listeningKeypress = elements.panels.listeningKeypress || [];
if(!elements.panels.listeningKeypress[key]){
elements.panels.listeningKeypress[key] = true;
window.addEventListener('keypress', function(e){
if(['input', 'textarea'].includes(document.activeElement.localName)) return;
if(elements[key] && e.key === 'Escape') core.panel.close(key);
});
}
},
close: function(key){
elements[key].classList.add('hidden');
elements[key].addEventListener('transitionend', function(e){
if(!elements[key]) return;
elements.panels.dataset.panels = parseInt(elements.panels.children.length - 1);
elements.panels.removeChild(elements[key]);
elements[key] = null;
}, {once: true});
},
toggle: function(key, create){
(!elements[key]) ? create() : core.panel.close(key);
},
},
addStyle: function(){
let style = createElement(core.html.style());
document.head.appendChild(style);
if(elements.style && elements.style.isConnected) document.head.removeChild(elements.style);
elements.style = style;
},
html: {
marks: {/*live(生), newcomer(新), first(初), last(終), bingeWatching(一挙), recommendation(注目), none(なし)*/
live: () => ``,
newcomer: () => ``,
first: () => ``,
last: () => ``,
bingeWatching: () => ``,
recommendation: () => ``,
none: () => `なし`,
},
myvideoButton: () => `
`,
repeatAllButton: () => `
`,
playButton: () => `
`,
notifyButton: () => `
`,
searchAllButton: (value, marks) => `
`,
clock: (hours, minutes) => `${hours}:${minutes}`,
searchHeader: () => `