// ==UserScript==
// @name AbemaTV Timetable Viewer
// @namespace knoa.jp
// @description AbemaTV に一覧性と操作性に優れた番組表と、気軽に登録できる通知機能を提供します。
// @include https://abema.tv/*
// @version 0.9.0
// @grant none
// ==/UserScript==
// console.log('AbemaTV? => hireMe()');
(function(){
const SCRIPTNAME = 'TimetableViewer';
const DEBUG = true;/*
[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 = ['番組なし', '番組告知', 'CM', ''];/*コンテンツなし番組タイトル(NOCONTENTS[0]はスクリプト内で共通のラベルとして使う)*/
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 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 = (NOCONTENTS.includes(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 hasNoContent(title){
return (NOCONTENTS.includes(title));
}
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 '';
}
}
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.buildNotificationsHeader();
core.timetable.listAllNotifications();
};
switch(true){
case(searchKey !== undefined):
if(!elements.timetablePanel.isConnected) core.timetable.createPanel();
core.timetable.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.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.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.observeChannelPane();
/* 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.modifyChannelPane();/*idなどを付与する*/
elements.closer.click();
setTimeout(function(){html.classList.remove('channelPaneHidden')}, 1000);/*チラ見せさせない*/
});
core.openHideChannelPane();
}, 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();
},
observeChannelPane: function(){
/* 裏番組一覧を常に改変する */
if(elements.channelPane.modifying === undefined) observe(elements.channelPane, function(records){
if(elements.channelPane.modifying) return;
elements.channelPane.modifying = true;
core.modifyChannelPane();/*アベマによる更新を上書きする*/
animate(function(){elements.channelPane.modifying = false});/*DOM処理の完了後に*/
}, {childList: true, characterData: true, subtree: true});
},
openHideChannelPane: function(){
/* ユーザーには閉じたように見せない */
html.classList.add('channelPaneHidden');/*開いても隠しておく*/
elements.channelButton.click();
},
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.scrollChannelPane(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');
}
},
scrollChannelPane: function(a){
let channelPane = elements.channelPane, child = channelPane.firstElementChild;
let pHeight = child.offsetHeight, aTop = a.offsetTop, aHeight = a.offsetHeight, innerHeight = window.innerHeight;
let scrollTo = function(scrollTop){
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 = '';
});
};
scrollTo(Math.min(Math.max(aTop - (innerHeight / 2) + (aHeight / 2), 0), pHeight - innerHeight -1/*端数対応*/));
},
modifyChannelPane: 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.modifyChannelPane);
/* サムネイルサイズの固定値(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.scrollChannelPane(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.hasNoContent(title.textContent)) 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);
}
}
},
listenUserActions: function(){
window.addEventListener('click', function(e){
switch(true){
/* アベマ公式の通知・マイビデオボタンが押されたら同期する */
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.modifyChannelPane();
core.timetable.fitName();
};
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;
}
},
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.buildSearch();
core.timetable.buildNotifications();
core.config.createButton();
core.panel.open('timetablePanel');
core.timetable.listenSelection();
core.timetable.shiftTimetable();
setTimeout(core.timetable.setupScrolls, 1000);
/* ScreenCommentScrollerの助けがない場合はここでChannelPaneのチャンネル切り替えイベントを流用できるようにしておく */
if(location.href.includes('/now-on-air/') && !site.get.screenCommentScroller()){
core.openHideChannelPane();
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();
});
}
},
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.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.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.fitName();
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);
},
buildSearch: 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.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.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.buildSearchHeader();
core.timetable.updateSearchFillters(value, marks);
core.timetable.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.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.listPrograms(ul, filteredResults);
core.timetable.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.buildNotificationsHeader();
core.timetable.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.listAllNotifications,
once: core.timetable.listOnceNotifications,
repeat: core.timetable.listRepeatNotifications,
search: core.timetable.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.listDaysNotifications(notifications.programs);
core.timetable.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.listDaysNotifications(filteredResults);
core.timetable.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.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.listPrograms(li.querySelector('ul'), repeats[key]);
ul.appendChild(li);
});
core.timetable.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.listPrograms(li.querySelector('ul'), searches[key].filter((p) => p.startAt <= limit));
ul.appendChild(li);
});
core.timetable.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;
}
},
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.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.search(value);
};
programDiv.addEventListener('mousedown', function(e){
programDiv.addEventListener('mouseup', function(e){
animate(function(){select(e)});/*ダブルクリックでのテキスト選択をanimateで確実に補足*/
}, {once: true});
});
},
fitName: function(){
if(!elements.channelsUl || !elements.channelsUl.isConnected) return;
let names = elements.channelsUl.querySelectorAll('.channel:not(.template) > header > .name');
for(let i = 0; names[i]; i++){
if(names[i].clientWidth < names[i].scrollWidth) names[i].style.transform = `scaleX(${names[i].clientWidth / names[i].scrollWidth})`;
else names[i].style.transform = '';
}
},
},
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.modifyChannelPane();
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: () => `<span class="mark live" ><svg height="14" width="14"><use xlink:href="/images/icons/text_live_rect.svg#svg-body"></use></svg><svg height="14" width="14"><use xlink:href="/images/icons/text_live_path.svg#svg-body"></use></svg></span>`,
newcomer: () => `<span class="mark newcomer"><svg height="14" width="14"><use xlink:href="/images/icons/text_newcomer_rect.svg#svg-body"></use></svg><svg height="14" width="14"><use xlink:href="/images/icons/text_newcomer_path.svg#svg-body"></use></svg></span>`,
first: () => `<svg class="mark first" height="14" width="14"><use xlink:href="/images/icons/text_new.svg#svg-body"></use></svg>`,
last: () => `<svg class="mark last" height="14" width="14"><use xlink:href="/images/icons/text_end.svg#svg-body"></use></svg>`,
bingeWatching: () => `<svg class="mark bingeWatching" height="14" width="23.333333333333336"><use xlink:href="/images/icons/text_binge_watching.svg#svg-body"></use></svg>`,
recommendation: () => `<svg class="mark recommendation" height="14" width="23.333333333333336"><use xlink:href="/images/icons/text_recommendation.svg#svg-body"></use></svg>`,
none: () => `<span class="mark none">なし</span>`,
},
myvideoButton: () => `
<button class="myvideo" data-title-default="マイビデオに追加する" data-title-active="マイビデオを解除する">
<svg width="16" height="16">
<use class="plus" xlink:href="/images/icons/plus.svg#svg-body"></use>
<use class="checked" xlink:href="/images/icons/checkmark.svg#svg-body"></use>
</svg>
</button>
`,
repeatAllButton: () => `
<button class="repeat_all" data-title-default="毎回通知を受け取る" data-title-active="毎回通知を解除する"><svg width="20" height="12"><use xlink:href="/images/icons/repeat.svg#svg-body"></use></svg></button>
`,
playButton: () => `
<button class="play" title="現在放送中"><svg width="17" height="17"><use xlink:href="/images/icons/play.svg#svg-body"></use></svg></button>
`,
notifyButton: () => `
<button class="notify" data-title-default="通知を受け取る" data-title-once="通知を解除する" data-title-repeat="毎回通知に登録済み" data-title-search="登録済みの検索通知を確認する">
<svg width="17" height="17">
<use class="plus" xlink:href="/images/icons/alarm_clock_plus.svg#svg-body"></use>
<use class="checked" xlink:href="/images/icons/alarm_clock_checkmark.svg#svg-body"></use>
<use class="repeat" xlink:href="/images/icons/repeat.svg#svg-body"></use>
<use class="search" xlink:href="/images/icons/search.svg#svg-body"></use>
</svg>
</button>
`,
searchAllButton: (value, marks) => `
<button class="search_all" data-title-default="検索結果を常に通知する" data-title-active="検索結果の通知を解除する"><svg width="17" height="17"><use xlink:href="/images/icons/search.svg#svg-body"></use></svg>${value} (${marks}) を常に通知</button>
`,
clock: (hours, minutes) => `<span class="clock">${hours}<span class="blink">:</span>${minutes}</span>`,
searchHeader: () => `
<header>
<p class="filters">
<input class="template" type="checkbox" name="filter" checked><label class="template"></label>
</p>
<p class="summary"><span class="count"></span></p>
</header>
`,
notificationsHeader: () => `
<header>
<nav class="tabs">
<input type="radio" name="notifications" value="all" id="notifications-all" checked><label for="notifications-all" >すべて</label>
<input type="radio" name="notifications" value="once" id="notifications-once" ><label for="notifications-once" ><svg width="17" height="17"><use xlink:href="/images/icons/alarm_clock_checkmark.svg#svg-body"></use></svg>1回通知</label>
<input type="radio" name="notifications" value="repeat" id="notifications-repeat" ><label for="notifications-repeat"><svg width="20" height="12"><use xlink:href="/images/icons/repeat.svg#svg-body"></use></svg>毎回通知</label>
<input type="radio" name="notifications" value="search" id="notifications-search" ><label for="notifications-search"><svg width="17" height="17"><use xlink:href="/images/icons/search.svg#svg-body"></use></svg>検索通知</label>
</nav>
<p class="summary"><span class="count"></span></p>
</header>
`,
programListItem: () => `
<li class="program">
<p class="thumbnail"></p>
<h2><span class="title"></span></h2>
<p class="data"><span class="date"></span><span class="channel"></span></p>
</li>
`,
noProgramListItem: () => `
<li class="noprogram">該当する番組はありません</li>
`,
dayListItem: () => `
<li class="day">
<h2></h2>
<ul></ul>
</li>
`,
repeatListItem: () => `
<li class="repeat">
<h2><span class="title"></span></h2>
<ul></ul>
</li>
`,
searchListItem: (value, marks) => `
<li class="search">
<h2><span class="key">${value} (${marks})</span></h2>
<ul></ul>
</li>
`,
timetablePanel: () => `
<div class="panel" id="${SCRIPTNAME}-timetable-panel">
<header>
<h1>番組表</h1>
<p class="buttons"><button class="config" title="${SCRIPTNAME} 設定"><svg width="20" height="20"><use xlink:href="/images/icons/config.svg#svg-body"></use></svg></button></p>
</header>
<div class="program nocontent">
<h2><span class="title">番組タイトル</span></h2>
<div class="thumbnails"></div>
<div class="summary">
<p class="channel">チャンネル</p>
<p class="date"><span>放送日時</span></p>
<p class="timeshift"><span>見逃し視聴</span></p>
<p class="highlight"></p>
<ul class="links">
<li class="template"><a></a></li>
</ul>
<div class="group">
<h3><span>今後${TERMLABEL}の放送予定</span></h3>
<ul>
<li class="template"><header><span class="date"></span><span class="title"></span></header></li>
</ul>
</div>
<div class="series">
<h3>(再放送などを含む)</h3>
<ul>
<li class="template"><header><span class="date"></span><span class="title"></span></header></li>
</ul>
</div>
</div>
<div class="content">
<h3>番組概要</h3>
<div></div>
</div>
<div class="casts">
<h3>キャスト</h3>
<ul></ul>
</div>
<div class="crews">
<h3>スタッフ</h3>
<ul></ul>
</div>
<p class="copyrights"></p>
</div>
<nav>
<div class="timeshift">
<p class="days"><input class="template" type="radio" name="day"><label class="template"></label></p>
<p class="times"><input class="template" type="radio" name="time"><label class="template"></label></p>
</div>
<p class="search">
<input type="search" name="q" placeholder="検索 (番組名、チャンネル名、キャスト、スタッフ)"><button class="search"><svg width="17" height="17"><use xlink:href="/images/icons/search.svg#svg-body"></use></svg></button>
<button class="notifications"><svg width="17" height="17"><use class="checked" xlink:href="/images/icons/alarm_clock_checkmark.svg#svg-body"></use></svg><span class="count"></span></button>
</p>
</nav>
<div class="programs">
<div class="search">
<ul>
</ul>
</div>
<ul class="channels">
<li class="time hidden animate">
<header><button class="now disabled" title="現在時刻に戻る"><span class="arrows">‹‹</span></button></header>
<ul class="stream times">
<li class="slot hour template"><span class="time"></span></li>
<li class="slot day template"><span class="date"></span></li>
</ul>
</li>
<li class="channel template hidden animate">
<header><h2 class="name"></h2></header>
<ul class="stream programs">
<li class="slot program template hidden"><span class="time"></span><span class="title"></span></li>
</ul>
</li>
</ul>
<p class="scrollers">
<button class="left disabled" aria-label="表示を左に移動"><svg height="20" width="12"><use xlink:href="/images/icons/chevron_left.svg#svg-body"></use></svg></button>
<button class="right disabled" aria-label="表示を右に移動"><svg height="20" width="12"><use xlink:href="/images/icons/chevron_right.svg#svg-body"></use></svg></button>
</p>
</div>
<p class="buttons"><button class="ok primary">OK</button></p>
</div>
`,
configPanel: () => `
<div class="panel" id="${SCRIPTNAME}-config-panel">
<h1>${SCRIPTNAME}設定</h1>
<fieldset>
<legend>番組表パネル</legend>
<p><label>透明度(%): <input type="number" name="transparency" value="${configs.transparency}" min="0" max="100" step="5"></label></p>
<p><label>番組表の高さ(%)(文字サイズ連動): <input type="number" name="height" value="${configs.height}" min="5" max="95" step="5"></label></p>
<p><label>番組表の時間幅(時間): <input type="number" name="span" value="${configs.span}" min="1" max="24" step="1"></label></p>
<p><label>アベマ公式の番組表を置き換える: <input type="checkbox" name="replace" value="${configs.replace}" ${configs.replace ? 'checked' : ''}></label></p>
</fieldset>
<fieldset>
<legend>通知(abema.tvを開いているときのみ)</legend>
<p><label>番組開始何秒前に通知するか(秒): <input type="number" name="n_before" value="${configs.n_before}" min="0" max="600" step="1"></label></p>
<p><label>自動でチャンネルも切り替える: <input type="checkbox" name="n_change" value="${configs.n_change}" ${configs.n_change ? 'checked' : ''}></label></p>
<p class="sub ${configs.n_change ? '' : 'disabled'}"><label>時間帯が重なっている時は通知のみ: <input type="checkbox" name="n_overlap" value="${configs.n_overlap}" ${configs.n_overlap ? 'checked' : ''} ${configs.n_change ? '' : 'disabled'}></label></p>
<p><label>アベマ公式の通知と共有する: <input type="checkbox" name="n_sync" value="${configs.n_sync}" ${configs.n_sync ? 'checked' : ''}></label></p>
</fieldset>
<fieldset>
<legend>表示するチャンネル</legend>
<ul class="channels">
<li class="template"><label><input type="checkbox" name="c_visibles" value="id"><span>チャンネル名</span></label></li>
</ul>
</fieldset>
<p class="buttons"><button class="cancel">キャンセル</button><button class="save primary">保存</button></p>
</div>
`,
panels: () => `
<div class="panels" id="${SCRIPTNAME}-panels"></div>
`,
style: () => `
<style type="text/css">
/* 共通変数 */
/* visible_channels: ${configs.visible_channels = Object.keys(configs.c_visibles).filter((id) => configs.c_visibles[id] === 1).length || 25} */
/* channelPane_width: ${configs.channelPane_width = 25} */
/* channelPane_rowheight: ${configs.channelPane_roweight = 100 / 15.5} (.5は番組表リンクの高さ) */
/* channelPane_thumbheight: ${configs.channelPane_thumbheight = configs.channelPane_roweight * .750} */
/* channelPane_padding: ${configs.channelPane_padding = configs.channelPane_roweight * .125} */
/* channelPane_lineheight: ${configs.channelPane_lineheight = configs.channelPane_roweight * .375} */
/* channelPane_fontsize: ${configs.channelPane_fontsize = configs.channelPane_roweight * .225} */
/* opacity: ${configs.opacity = 1 - (configs.transparency / 100)} */
/* scrollbarWidth: ${configs.scrollbarWidth = getScrollbarWidth()} */
/* rowheight: ${configs.rowheight = configs.height / (configs.visible_channels + 1)} */
/* rowfontsize: ${configs.rowfontsize = Math.min(1.6, configs.rowheight * .6)} */
/* lineheight: ${configs.lineheight = 2.8} */
/* fontsize: ${configs.fontsize = 1.6} */
/* search_lineheight: ${configs.search_lineheight = Math.max(1.8, configs.rowfontsize * Math.min(configs.height/50, 1) * 1.5)} */
/* search_fontsize: ${configs.search_fontsize = Math.max(1.2, configs.rowfontsize * Math.min(configs.height/50, 1))} */
/* transparentGray: ${configs.transparentGray = `rgba(255,255,255,.5)`} */
/* link_color: ${configs.link_color = `rgba( 81,195, 0,1)`} */
/* listed_background: ${configs.listed_background = `rgba( 96, 96, 96,${configs.opacity})`} */
/* listed_backgroundHover: ${configs.listed_backgroundHover = `rgba( 96, 96, 96,${configs.opacity / 2})`} */
/* times_background: ${configs.times_background = `rgba( 32, 32, 32,${configs.opacity / 2})`} */
/* nowOnAir_background: ${configs.nowOnAir_background = `rgba( 96, 96, 96,${configs.opacity})`} */
/* comming_background: ${configs.comming_background = `rgba( 64, 64, 64,${configs.opacity})`} */
/* noContent_background: ${configs.noContent_background = `rgba( 32, 32, 32,${configs.opacity})`} */
/* hover_background: ${configs.hover_background = `rgba(168,224,128,${configs.opacity})`} */
/* current_background: ${configs.current_background = `rgba( 81,195, 0,${configs.opacity})`} */
/* scroller_background: ${configs.scroller_background = `rgba(255,255,255,${configs.opacity})`} */
/* search_background: ${configs.search_background = `rgba( 0, 0, 0,${configs.opacity / 2})`} */
/* border_color: ${configs.border_color = `rgba( 0, 0, 0,${configs.opacity})`} */
/* activeButton_color: ${configs.activeButton_color = `rgba( 81,195, 0,1)`} */
/* progressbar_zIndex: ${configs.progressbar_zIndex = 110} */
/* panel_zIndex: ${configs.panel_zIndex = 100} */
/* channelPane_zIndex: ${configs.channelPane_zIndex = 11} */
/* scrollers_zIndex: ${configs.scrollers_zIndex = 10} */
/* button_zIndex: ${configs.button_zIndex = 10} */
/* search_zIndex: ${configs.search_zIndex = 10} */
/* program_zIndex: ${configs.program_zIndex = 1} */
/* nav_transition: ${configs.nav_transition = '500ms cubic-bezier(.17,.84,.44,1)'} (Quartic) */
/* アベマ公式の不要要素 */
/* (レイアウトを崩す謎要素に、とりあえず穏便に表示位置の調整で対応する) */
.pub_300x250,
.pub_300x250m,
.pub_728x90,
.text-ad,
.textAd,
.text_ad,
.text_ads,
.text-ads,
.text-ad-links,
#announcer,
dummy{
position: absolute;
bottom: 0;
}
/* マーク共通 */
.mark,
.mark > *{
width: 1em !important;
}
.mark.bingeWatching,
.mark.recommendation{
width: ${5/3}em !important;
}
.mark.none{
width: 2em !important;
height: auto !important;
}
.mark{
fill: white;
margin: 0 .2em 0 0;
}
span.mark{
vertical-align: middle;
position: relative;
display: inline-block;
}
.mark > svg{
vertical-align: middle;
position: absolute;
left: 0;
top: 0;
}
.mark.newcomer > svg:nth-child(1),
.mark.live > svg:nth-child(1){
fill: #f0163a;
}
.mark.last{
margin-right: 0;
margin-left: .2em;
}
/* 通知その他ボタン共通 */
button.myvideo,
button.repeat_all,
button.play,
button.notify{
padding: 5px;/*クリッカブル領域を広げる*/
margin: -5px;
box-sizing: content-box;
position: relative;/*個別調整用*/
z-index: ${configs.button_zIndex};
pointer-events: auto;
transition: transform 250ms ease-in;
}
button.myvideo > *,
button.repeat_all > *,
button.play > *,
button.notify > *{
fill: white;
width: auto;
border-radius: 50vmax;
overflow: visible;/*目覚まし時計アイコンのベル部分*/
transform: scaleX(1);
pointer-events: none;
}
button.repeat_all > *{
position: relative;
bottom: .1em !important;/*微調整*/
}
button.notify.once > *{
fill: ${configs.activeButton_color};
background: white;
padding: 0;
border: .05em solid white;
box-sizing: border-box;
overflow: hidden;/*目覚まし時計アイコンのベル部分*/
}
button.notify.repeat > *{
fill: white;
background: ${configs.activeButton_color};
padding: .05em 0 0 .05em;
border: .1em solid white;
box-sizing: border-box;
overflow: visible;
}
button.notify.search > *{
fill: white;
background: ${configs.activeButton_color};
padding: .1em;
border: .1em solid white;
box-sizing: border-box;
overflow: visible;
}
button.myvideo:hover,
button.repeat_all:hover,
button.play:hover,
button.notify:hover{
filter: brightness(.5);
}
button.myvideo.reversing,
button.repeat_all.reversing,
button.play.reversing,
button.notify.reversing{
transform: scaleX(0);
transition: transform 250ms ease-out;
}
button.repeat_all:not(.active) > *{
transform: scaleX(-1);
}
button.myvideo.active > *,
button.repeat_all.active > *,
button.play.current > *{
fill: ${configs.activeButton_color};
filter: brightness(1.25);/*視認性を高める*/
}
button.myvideo use,
button.notify use{
display: none;
}
button.myvideo:not(.active) use.plus,
button.myvideo.active use.checked,
button.notify:not(.once):not(.repeat):not(.search) use.plus,
button.notify.once:not(.repeat):not(.search) use.checked,
button.notify.repeat:not(.search) use.repeat,
button.notify.search use.search{
display: inline;
}
/* アベマ公式 裏番組一覧の表示非表示 */
html.channelPaneHidden [data-selector="channelPane"]{
opacity: 0;/*translateXでは読み込みが発生しない*/
z-index: -1;
transition: opacity 500ms ease 500ms;/*チラ見えさせない努力*/
}
html.channelPaneHidden [data-selector="screen"] > div[style]{
width: 100% !important;
height: 100% !important;
}
/* アベマ公式 裏番組一覧の改変 */
/* (アベマ公式に上書きされがちなので独自セレクタは使いにくい) */
[data-selector="channelPane"]{
width: 25vw;
min-width: 40vh;
}
[data-selector="channelPane"] > div{
min-height: 100%;
display: flex;
flex-direction: column;
}
[data-selector="channelPane"] > div > a{
height: ${configs.channelPane_roweight}vh;
border-left: none !important;/*現在チャンネルに付く公式のボーダーをなくす*/
padding: 0;
width: 100%;
overflow: hidden;
}
[data-selector="channelPane"] > div > a ~ a{
border-top: 1px solid ${configs.border_color} !important;/*公式のボーダーを置き換える*/
}
[data-selector="channelPane"] > div > a[data-hidden="true"]{
display: none;
}
[data-selector="channelPane"] > div > a > div{
position: relative;
}
[data-selector="channelPane"] > div > a > div > div:nth-child(1)/*サムネイル*/{
border-right: 1px solid ${configs.border_color};
background: ${configs.nowOnAir_background};
padding: ${configs.channelPane_padding}vh;
box-sizing: content-box;
}
[data-selector="channelPane"] > div > a > div > div:nth-child(1)/*サムネイル*/ > div > div{
height: ${configs.channelPane_thumbheight}vh !important;
width: ${configs.channelPane_thumbheight * (16/9)}vh !important;
}
[data-selector="channelPane"] > div > a > div > div:nth-child(1)/*サムネイル*/ > div > div > img{
min-height: 100%;
min-width: 100%;
}
[data-selector="channelPane"] > div > a > div > div:nth-child(1)/*サムネイル*/ > div > div + img/*チャンネル名*/{
width: ${configs.channelPane_thumbheight * (16/9)}vh;
height: auto;
}
[data-selector="channelPane"] > div > a > div > div ~ div/*番組共通*/,
[data-selector="channelPane"] > div > a:last-child/*番組表リンク*/{
border-right: 1px solid ${configs.border_color};
background: ${configs.comming_background};
padding: ${configs.channelPane_padding}vh 0 !important;/*公式の左パディングを打ち消す*/
overflow: hidden;
min-width: 0;/*中身が長くても伸ばさずすぐあふれさせる*/
position: absolute;
}
[data-selector="channelPane"] > div > a > div > div ~ div/*番組共通*/{
clip-path: inset(0 -100vw 0 0);/*overflowさせるのは右方向だけ*/
background-clip: padding-box !important;/*border-leftの色を保証*/
height: 100%;
}
[data-selector="channelPane"] > div > a > div > div ~ div/*番組共通*/.transition/*ハイライトを付与する際のトランジション*/{
transition: background 500ms ease;
}
[data-selector="channelPane"] > div > a > div > div:nth-child(2)/*放送中の番組*/{
background: ${configs.nowOnAir_background};
}
[data-selector="channelPane"] > div > a > div > div ~ div > div/*タイトルと放送時間*/{
margin-left: ${configs.channelPane_padding}vh;
}
[data-selector="channelPane"] > div > a > div > div ~ div > div/*タイトルと放送時間*/,
[data-selector="channelPane"] > div > a > div > div ~ div > div/*タイトルと放送時間*/ *{
font-weight: normal !important;;/*公式の現在チャンネルの太字を打ち消す*/
font-size: ${configs.channelPane_fontsize}vh;
line-height: ${configs.channelPane_lineheight}vh;
margin-bottom: 0;/*公式の下マージンを打ち消す*/
white-space: nowrap;
}
[data-selector="channelPane"] > div > a > div > div + div ~ div:not(.active):not(:hover)/*後続番組*/{
filter: brightness(.75);
}
[data-selector="channelPane"] > div > a > div > div ~ div > div:nth-child(1)/*タイトル*/ > span,
[data-selector="channelPane"] > div > a > div > div ~ div > div:nth-child(2)/*放送時間*/{
display: flex;
align-items: center;
}
[data-selector="channelPane"] > div > a > div > div ~ div > div:nth-child(1)/*タイトル*/ > span > span[style],
[data-selector="channelPane"] > div > a > div > div ~ div > div:nth-child(1)/*タイトル*/ > span > span[style] > *,
[data-selector="channelPane"] > div > a > div > div ~ div > div:nth-child(1)/*タイトル*/ > span > svg[width],
[data-selector="channelPane"] > div > a > div > div ~ div > div:nth-child(1)/*タイトル*/ > span > .mark,
[data-selector="channelPane"] > div > a > div > div ~ div > div:nth-child(1)/*タイトル*/ > span > .mark *{
font-size: ${configs.channelPane_fontsize}vh;
height: ${configs.channelPane_fontsize}vh !important;
width: 1em !important;
min-width: 1em !important;/*なぜかこれを指定しないとレイアウトが崩れる要素が発生する*/
}
[data-selector="channelPane"] > div > a > div > div ~ div > div:nth-child(1)/*タイトル*/ > span > svg[width^="23"]{
width: ${5/3}em !important;/*ちょっとトリッキーだが…*/
min-width: ${5/3}em !important;
}
[data-selector="channelPane"] > div > a > div > div ~ div:not(:hover) > div:nth-child(2)/*放送時間*/ *{
color: ${configs.transparentGray};
}
[data-selector="channelPane"] > div > a > div > div ~ div > div:nth-child(2)/*放送時間*/ > button/*通知ボタン*/,
[data-selector="channelPane"] > div > a > div > div ~ div > div:nth-child(2)/*放送時間*/ > button/*通知ボタン*/ *{
height: ${configs.channelPane_lineheight}vh;
}
[data-selector="channelPane"] > div > a > div > div ~ div > div:nth-child(2)/*放送時間*/ > button/*通知ボタン*/{
padding: 0;
margin: 0 ${configs.channelPane_padding}vh 0 calc(-${configs.channelPane_padding}vh - ${configs.channelPane_lineheight}vh - 1px/*端数対応*/);
transition: margin 500ms ease, transform 250ms ease-in;;
pointer-events: auto !important;
}
[data-selector="channelPane"] > div > a > div > div ~ div:hover > div:nth-child(2)/*放送時間*/ > button/*通知ボタン*/{
margin: 0 .2em 0 0;
}
[data-selector="channelPane"] > div > a[data-current="true"] > div > div:nth-child(1)/*サムネイル*/,
[data-selector="channelPane"] > div > a[data-current="true"] > div > div:nth-child(1) + div/*放送中の番組*/,
[data-selector="channelPane"] > div > a > div > div:nth-child(1) ~ div.active/*通知番組*/{
background: ${configs.current_background} !important;
}
[data-selector="channelPane"] > div > a[data-current="true"] > div > div:nth-child(1) + div > */*放送中の番組*/,
[data-selector="channelPane"] > div > a > div > div:nth-child(1) ~ div.active > */*通知番組*/{
filter: brightness(100%) !important;/*nocontentの指定を上書き*/
}
[data-selector="channelPane"] > div > a:hover{
opacity: 1;/*公式の不透明度をなくす*/
}
[data-selector="channelPane"] > div > a:hover > div > div:nth-child(1)/*サムネイル*/,
[data-selector="channelPane"] > div > a:hover > div > div:nth-child(1)/*サムネイル*/ ~ div/*番組*/{
background: ${configs.hover_background};
}
[data-selector="channelPane"] > div > a > div > div:nth-child(1):hover/*サムネイル*/ + div/*放送中の番組*/,
[data-selector="channelPane"] > div > a > div > div:nth-child(1)/*サムネイル*/ ~ div:hover/*番組*/{
overflow: visible;
z-index: ${configs.program_zIndex};
}
[data-selector="channelPane"] > div > a > div > div:nth-child(1):hover/*サムネイル*/ + div */*放送中の番組の中身*/,
[data-selector="channelPane"] > div > a > div > div:nth-child(1)/*サムネイル*/ ~ div:hover */*番組の中身*/{
color: white !important;
pointer-events: none;/*overflowしていても後続番組にhoverをゆずる*/
}
[data-selector="channelPane"] > div > a:hover > div > div:nth-child(1):not(:hover)/*サムネイル*/ + div:not(:hover)/*放送中の番組*/,
[data-selector="channelPane"] > div > a:hover > div > div:nth-child(1)/*サムネイル*/ + div ~ div:not(:hover)/*後続番組*/{
filter: brightness(.5);
}
[data-selector="channelPane"] > div > a > div > div:nth-child(1):hover/*サムネイル*/ + div ~ div > */*後続番組の中身*/,
[data-selector="channelPane"] > div > a > div > div:nth-child(1)/*サムネイル*/ ~ div:hover/*番組*/ ~ div > */*後続番組の中身*/{
opacity: .25;
}
[data-selector="channelPane"] > div > a:not(:hover) > div > div:nth-child(1)/*サムネイル*/ ~ div.nocontent{
background: ${configs.noContent_background};
}
[data-selector="channelPane"] > div > a:not(:hover) > div > div:nth-child(1)/*サムネイル*/ ~ div.nocontent > *{
filter: brightness(25%);
}
[data-selector="channelPane"] > div > a:last-child/*番組表リンク*/{
font-size: ${configs.channelPane_fontsize}vh;
line-height: ${configs.channelPane_roweight * .4}vh;/*端数ズレが起きるため理論値の.5に対して余裕を持たせる*/
padding: 0 !important;
position: relative;
flex: 1;
}
[data-selector="channelPane"] > div > a:last-child:hover/*番組表リンク*/{
background: rgba(128,128,128,.5);
}
[data-selector="channelPane"] > div > a:last-child/*番組表リンク*/ > svg{
height: ${configs.channelPane_fontsize}vh;
}
/* アベマ公式 番組表の置き換え */
html.abemaTimetable #splash > div{
display: block;/*公式のnoneを上書き*/
}
html.abemaTimetable main *{/*負荷の低減を試みる*/
transition: none !important;
animation: none !important;
pointer-events: none !important;
}
html.abemaTimetable [data-selector="progressbar"]{
display: none;
}
/* アベマ公式 プログレスバー */
[data-selector="progressbar"]{
z-index: ${configs.progressbar_zIndex};
}
/* パネル共通 */
#${SCRIPTNAME}-panels{
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
overflow: hidden;
pointer-events: none;
}
#${SCRIPTNAME}-panels div.panel{
position: absolute;
max-height: 100%;/*小さなウィンドウに対応*/
overflow: auto;
left: 50%;
bottom: 50%;
transform: translate(-50%, 50%);
z-index: ${configs.panel_zIndex};
background: rgba(0,0,0,.75);
transition: ${configs.nav_transition};
padding: 5px 0;
pointer-events: auto;
}
#${SCRIPTNAME}-panels div.panel.hidden{
bottom: 0;
transform: translate(-50%, 100%);
}
#${SCRIPTNAME}-panels h1,
#${SCRIPTNAME}-panels h2,
#${SCRIPTNAME}-panels h3,
#${SCRIPTNAME}-panels h4,
#${SCRIPTNAME}-panels legend,
#${SCRIPTNAME}-panels li,
#${SCRIPTNAME}-panels dt,
#${SCRIPTNAME}-panels dd,
#${SCRIPTNAME}-panels code,
#${SCRIPTNAME}-panels p{
color: rgba(255,255,255,1);
font-size: 14px;
padding: 2px 10px;
line-height:20px;
}
#${SCRIPTNAME}-panels header{
display: flex;
}
#${SCRIPTNAME}-panels header h1{
flex: 1;
}
#${SCRIPTNAME}-panels > div.panel > p.buttons{
text-align: right;
padding: 5px 10px;
}
#${SCRIPTNAME}-panels > div.panel > p.buttons button{
width: 120px;
padding: 5px 10px;
margin-left: 10px;
border-radius: 5px;
color: rgba(255,255,255,1);
background: rgba(64,64,64,1);
border: 1px solid rgba(255,255,255,1);
}
#${SCRIPTNAME}-panels > div.panel > p.buttons button.primary{
font-weight: bold;
background: rgba(0,0,0,1);
}
#${SCRIPTNAME}-panels > div.panel > p.buttons button:hover,
#${SCRIPTNAME}-panels > div.panel > p.buttons button:focus{
background: rgba(128,128,128,.75);
}
#${SCRIPTNAME}-panels .template{
display: none !important;
}
/* 番組表パネル */
#${SCRIPTNAME}-timetable-panel{
background: rgba(0,0,0,${configs.opacity}) !important;
width: 100% !important;
height: 100% !important;
padding: 0 !important;
overflow: hidden !important;
display: grid;
grid-template-columns: auto;
grid-template-rows: 1fr auto ${configs.height}vh;
}
#${SCRIPTNAME}-timetable-panel > header > h1{
display: none;
}
#${SCRIPTNAME}-timetable-panel > header > p.buttons{
padding: 5px 10px;
position: fixed;
top: 0;
right: 0;
z-index: ${configs.panel_zIndex};
}
#${SCRIPTNAME}-timetable-panel button.config{
fill: white;
filter: drop-shadow(0 0 2.5px rgba(0,0,0,.75));
padding: 5px;
margin: -5px;
height: 30px;
}
#${SCRIPTNAME}-timetable-panel button.config:hover{
filter: brightness(.5);
}
#${SCRIPTNAME}-timetable-panel > .program{
grid-column: 1;
grid-row: 1;
}
#${SCRIPTNAME}-timetable-panel > nav{
grid-column: 1;
grid-row: 2;
}
#${SCRIPTNAME}-timetable-panel > .programs{
grid-column: 1;
grid-row: 3;
overflow: hidden;
}
#${SCRIPTNAME}-timetable-panel > p.buttons:last-of-type{
position: absolute;
bottom: 0;
right: 0;
z-index: ${configs.panel_zIndex};
}
#${SCRIPTNAME}-timetable-panel h1,
#${SCRIPTNAME}-timetable-panel h2,
#${SCRIPTNAME}-timetable-panel h3,
#${SCRIPTNAME}-timetable-panel h4,
#${SCRIPTNAME}-timetable-panel legend,
#${SCRIPTNAME}-timetable-panel li,
#${SCRIPTNAME}-timetable-panel dt,
#${SCRIPTNAME}-timetable-panel dd,
#${SCRIPTNAME}-timetable-panel code,
#${SCRIPTNAME}-timetable-panel p{
padding: .1em .5em;
}
/* 番組情報 */
#${SCRIPTNAME}-timetable-panel > .program{
word-wrap: break-word;
margin-right: -${configs.scrollbarWidth}px;/*スクロールバーを隠す*/
-webkit-mask-image: linear-gradient(black 90%, rgba(0,0,0,.5));/*まだ-webkit取れない*/
mask-image: linear-gradient(black 90%, rgba(0,0,0,.5));
position: relative;
overflow-x: hidden;
overflow-y: scroll;
display: grid;
grid-template-columns: 2fr 2fr 2fr 1fr 1fr;
grid-template-rows: ${configs.lineheight * 1.5}vh auto ${configs.lineheight * .825}vh;
transition: opacity 500ms ease;
}
#${SCRIPTNAME}-timetable-panel > .program > .thumbnails,
#${SCRIPTNAME}-timetable-panel > .program > .summary,
#${SCRIPTNAME}-timetable-panel > .program > .content{
max-width: 25vw;/*max指定しておけばword-break-allしなくてすむ*/
}
#${SCRIPTNAME}-timetable-panel > .program > .casts,
#${SCRIPTNAME}-timetable-panel > .program > .crews{
max-width: 12.5vw;
}
#${SCRIPTNAME}-timetable-panel > .program *{
font-size: ${configs.fontsize}vh;
line-height: ${configs.lineheight}vh;
}
#${SCRIPTNAME}-timetable-panel > .program a{
color: ${configs.link_color};
text-decoration: none;
filter: brightness(1.25);/*視認性を高める*/
}
#${SCRIPTNAME}-timetable-panel > .program a:hover{
filter: brightness(.5);
}
#${SCRIPTNAME}-timetable-panel > .program.nocontent{
opacity: .25;
}
/* 番組情報 タイトル */
#${SCRIPTNAME}-timetable-panel > .program > h2{
white-space: nowrap;
grid-column: 2 / 6;
grid-row: 1;
}
#${SCRIPTNAME}-timetable-panel > .program > h2 *{
font-size: ${configs.fontsize * 1.5}vh;
line-height: ${configs.lineheight * 1.5}vh;
vertical-align: middle;
}
#${SCRIPTNAME}-timetable-panel > .program > h2 > .mark,
#${SCRIPTNAME}-timetable-panel > .program > h2 > .mark *{
height: ${configs.fontsize * 1.5}vh !important;
}
/* 番組情報 サムネイル */
#${SCRIPTNAME}-timetable-panel > .program > .thumbnails{
grid-column: 1;
grid-row: 1 / 4;
}
#${SCRIPTNAME}-timetable-panel > .program > .thumbnails img:first-child{
width: calc(100% - 2vh);
}
#${SCRIPTNAME}-timetable-panel > .program > .thumbnails img{
width: calc(50% - 1.5vh);
margin: 1vh 0 0 1vh;
transition: opacity 500ms ease;
}
#${SCRIPTNAME}-timetable-panel > .program > .thumbnails img.loading{
opacity: 0;
}
/* 番組情報 サマリ */
#${SCRIPTNAME}-timetable-panel > .program > .summary{
grid-column: 2;
grid-row: 2 / 4;
}
#${SCRIPTNAME}-timetable-panel > .program > .summary .inactive{
display: none;
}
#${SCRIPTNAME}-timetable-panel > .program > .summary h3 > *{
vertical-align: middle;
}
#${SCRIPTNAME}-timetable-panel > .program > .summary button,
#${SCRIPTNAME}-timetable-panel > .program > .summary button > *{
width: calc(${configs.fontsize}vh + .2em);/*限界まで膨れさせる*/
height: calc(${configs.fontsize}vh + .2em);
bottom: -.2em;
}
#${SCRIPTNAME}-timetable-panel > .program > .summary button.repeat_all,
#${SCRIPTNAME}-timetable-panel > .program > .summary button.repeat_all > *{
width: calc(${configs.fontsize}vh + .4em);/*限界まで膨れさせる*/
height: calc(${configs.fontsize}vh + .4em);
bottom: 0em;
}
#${SCRIPTNAME}-timetable-panel > .program > .summary button + *{
margin-left: .25em;
}
#${SCRIPTNAME}-timetable-panel > .program > .summary button + .date + *{
margin-left: 1em;
}
#${SCRIPTNAME}-timetable-panel > .program > .summary li header{
background: ${configs.listed_background};
border-radius: calc(${configs.lineheight / 2}vh);
padding: .1em calc(${configs.fontsize / 2}vh - .1em) .1em 0;
display: inline;
cursor: pointer;
}
#${SCRIPTNAME}-timetable-panel > .program > .summary li header .mark,
#${SCRIPTNAME}-timetable-panel > .program > .summary li header .mark *{
height: ${configs.fontsize}vh;
vertical-align: text-bottom;
}
#${SCRIPTNAME}-timetable-panel > .program > .summary li header:hover,
#${SCRIPTNAME}-timetable-panel > .program > .summary li.current header{
color: ${configs.transparentGray};
background: ${configs.listed_backgroundHover};
}
#${SCRIPTNAME}-timetable-panel > .program > .summary li header:hover .mark,
#${SCRIPTNAME}-timetable-panel > .program > .summary li.current header .mark{
filter: brightness(.5);
}
#${SCRIPTNAME}-timetable-panel > .program > .summary li.current header{
cursor: auto;
}
#${SCRIPTNAME}-timetable-panel > .program > .summary li header *{
vertical-align: baseline;/*headerが既にinline要素なので*/
}
/* 番組情報 番組概要 */
#${SCRIPTNAME}-timetable-panel > .program > .content{
grid-column: 3;
grid-row: 2 / 4;
}
/* 番組情報 キャスト・スタッフ */
#${SCRIPTNAME}-timetable-panel > .program > .casts{
grid-column: 4;
grid-row: 2 / 3;
}
#${SCRIPTNAME}-timetable-panel > .program > .crews{
grid-column: 5;
grid-row: 2 / 3;
}
#${SCRIPTNAME}-timetable-panel > .program > .casts .name,
#${SCRIPTNAME}-timetable-panel > .program > .crews .name{
color: black;
background: white;
padding: 1px calc(${configs.fontsize / 2}vh - .2em);
border-radius: calc(${configs.lineheight / 2}vh);
margin: 0 1px;
cursor: pointer;
}
#${SCRIPTNAME}-timetable-panel > .program > .casts .name:hover,
#${SCRIPTNAME}-timetable-panel > .program > .crews .name:hover{
filter: brightness(.5);
}
/* 番組情報 コピーライト */
#${SCRIPTNAME}-timetable-panel > .program > .copyrights{
font-size: ${configs.fontsize * .825}vh;
line-height: ${configs.lineheight * .825}vh;
text-align: right;
padding: 0 1vh;
grid-column: 3 / 6;
grid-row: 3;
}
/* 番組表ナビゲーション */
#${SCRIPTNAME}-timetable-panel > nav{
display: flex;
}
#${SCRIPTNAME}-timetable-panel > nav > .timeshift{
flex: 1;
}
#${SCRIPTNAME}-timetable-panel > nav > .search{
width: ${100*(1/3)}vw;
}
/* 番組表ナビゲーション 日付・時間 */
#${SCRIPTNAME}-timetable-panel > nav > .timeshift{
display: flex;
align-items: center;
}
#${SCRIPTNAME}-timetable-panel > nav > .timeshift .days{
width: calc((${100*(2/3)}vw - ${NAMEWIDTH}vw)/2 + ${NAMEWIDTH}vw - 2vh);
}
#${SCRIPTNAME}-timetable-panel > nav > .timeshift .times{
width: calc((${100*(2/3)}vw - ${NAMEWIDTH}vw)/2);
}
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .days,
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .times{
font-size: ${configs.rowfontsize}vh;
line-height: ${configs.rowheight}vh;
height: ${configs.rowheight}vh;
padding: 0;
margin: .2em 0 .2em 1vh;
border-radius: .5em;
overflow: hidden;
display: flex;
}
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .days input,
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .times input{
display: none;
}
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .days label,
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .times label{
color: white;
background: rgba(96,96,96,.25);
text-align: center;
white-space: nowrap;
width: 100%;
margin-left: 1px;
padding: 0 1px;
min-width: 1em;
overflow: hidden;
flex: 1;
}
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .days label:first-of-type{
min-width: calc(${NAMEWIDTH}vw - 1vh - 1px);
}
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .days label:first-of-type,
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .times label:first-of-type{
margin-left: 0;
}
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .days input:not(:checked) + label,
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .times input:not(:checked) + label{
cursor: pointer;
}
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .days input:checked + label,
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .days label:hover,
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .days label:focus,
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .times input:checked + label,
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .times label:hover,
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .times label:focus{
background: rgba(96,96,96,.75);
}
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .days label.sat{
background: rgba(96,128,160,.25);
}
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .days label.sun{
background: rgba(160,96,96,.25);
}
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .days input:checked + label.sat,
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .days label.sat:hover,
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .days label.sat:focus{
background: rgba(96,128,160,.75);
}
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .days input:checked + label.sun,
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .days label.sun:hover,
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .days label.sun:focus{
background: rgba(160,96,96,.75);
}
#${SCRIPTNAME}-timetable-panel > nav > .timeshift > .times input:disabled + label{
color: black;
opacity: .25;
cursor: auto;
}
/* 番組表ナビゲーション 検索 */
#${SCRIPTNAME}-timetable-panel > nav > .search{
font-size: ${configs.fontsize}vh;
line-height: ${configs.lineheight}vh;
padding: 0;
display: flex;
align-items: center;
}
#${SCRIPTNAME}-timetable-panel > nav > .search > input[type="search"]{
border: 1px solid transparent;/*ブラウザデフォルトスタイルの解消も兼ねる*/
border-radius: .2em 0 0 .2em;
width: 100%;
min-width: 0;/*幅が足りなければ素直に縮ませる*/
padding: 0 0 0 .5em;
margin: .2em 0 .2em 1vh;
}
#${SCRIPTNAME}-timetable-panel > nav > .search > button.search{
background: rgb(192,192,192);
border: 1px solid transparent;
border-radius: 0 .2em .2em 0;
flex-shrink: 0;
padding: 0 .5em;
margin: .2em 1vh .2em 0;
}
#${SCRIPTNAME}-timetable-panel > nav > .search > button.search:hover{
filter: brightness(.5);
}
#${SCRIPTNAME}-timetable-panel > nav > .search > button.search > svg{
width: ${configs.fontsize}vh;
height: ${configs.fontsize}vh;
fill: white;
vertical-align: middle;
}
/* 番組表ナビゲーション 通知 */
#${SCRIPTNAME}-timetable-panel > nav > .search > button.notifications{
white-space: nowrap;
margin-right: 1vh;
display: flex;
align-items: center;
}
#${SCRIPTNAME}-timetable-panel > nav > .search > button.notifications:hover{
filter: brightness(.5);
}
#${SCRIPTNAME}-timetable-panel > nav > .search > button.notifications > *{
flex-shrink: 0;
}
#${SCRIPTNAME}-timetable-panel > nav > .search > button.notifications svg{
fill: ${configs.activeButton_color};
filter: brightness(1.25);/*視認性を高める*/
width: ${configs.rowheight}vh;
height: ${configs.rowheight}vh;
margin-right: .2em;
}
#${SCRIPTNAME}-timetable-panel > nav > .search > button.notifications[data-count="0"] svg{
fill: ${configs.transparentGray};
filter: brightness(1);
}
/* 番組表 */
#${SCRIPTNAME}-timetable-panel > .programs{
position: relative;
}
#${SCRIPTNAME}-timetable-panel .channels{
position: relative;
left: ${NAMEWIDTH}vw;
width: ${100 - NAMEWIDTH}vw;
padding: 0;
overflow-y: hidden;
transition: width 500ms ease;/*searchPaneの開閉用*/
}
#${SCRIPTNAME}-timetable-panel .search.active ~ .channels{
width: calc(${100*(2/3) - NAMEWIDTH}vw - 1px);
}
#${SCRIPTNAME}-timetable-panel .channels *{
color: white;
font-size: ${configs.rowfontsize}vh;
line-height: calc(${configs.rowheight}vh - 1px);/*borderぶん*/
white-space: nowrap;
}
#${SCRIPTNAME}-timetable-panel .channels > li{
height: ${configs.rowheight}vh;
padding: 0;
position: relative;
}
#${SCRIPTNAME}-timetable-panel .channels > li.channel{
overflow: hidden;
}
#${SCRIPTNAME}-timetable-panel .channels > li > header{
padding: 0;
border-top: 1px solid ${configs.border_color};
border-right: 1px solid ${configs.border_color};
text-align: center;
position: fixed;
left: 0;
width: ${NAMEWIDTH}vw;
overflow: hidden;/*これを指定しないとFirefoxでposition:fixedが狂うバグあり*/
cursor: pointer;
transition: transform 500ms ease;/*初回左右からの登場用*/
}
#${SCRIPTNAME}-timetable-panel .channels > li.channel > header{
background: ${configs.nowOnAir_background};
background-clip: padding-box !important;
}
#${SCRIPTNAME}-timetable-panel .channels > li.channel.notonair > header{
cursor: auto;/*放送してないチャンネルはクリッカブルにしない*/
}
#${SCRIPTNAME}-timetable-panel .channels > li > header > .name{
width: 100%;
padding: 0;
text-align: center;
transform-origin: left;
transition: transform 500ms ease;/*チャンネル名幅調整用*/
}
#${SCRIPTNAME}-timetable-panel .channels > li > .stream{
width: 100%;
border-top: 1px solid ${configs.border_color};
position: relative;
transition: transform 500ms ease;/*初回左右からの登場用(疑似スクロール処理時はjsで上書きする)*/
}
#${SCRIPTNAME}-timetable-panel .channels > li.animate,
#${SCRIPTNAME}-timetable-panel .channels > li.animate *{
pointer-events: none !important;/*position:fixedバグの回避*/
}
#${SCRIPTNAME}-timetable-panel .channels > li.animate > header,
#${SCRIPTNAME}-timetable-panel .channels > li.animate > .stream{
/*will-change: transform;*//*position:fixedが狂うバグが発生するのでやむなくコメントアウト*/
}
#${SCRIPTNAME}-timetable-panel .channels > li.hidden > header{
transform: translateX(-${NAMEWIDTH}vw);/*これもposition:fixedが狂うバグに関与している*/
}
#${SCRIPTNAME}-timetable-panel .channels > li.hidden > .stream{
transform: translateX(${100 - NAMEWIDTH}vw);
}
/* 番組表 時刻・個別番組共通 */
#${SCRIPTNAME}-timetable-panel .channels .slot{
padding: 0;
border-right: 1px solid ${configs.border_color};
position: absolute;
overflow: hidden;
display: flex;
align-items: center;/*.markを中央揃え*/
cursor: pointer;
transition: opacity 250ms ease;/*出現時(1分シフト時はjsで上書きする)*/
}
#${SCRIPTNAME}-timetable-panel .channels .slot.transition/*ハイライトを付与する際専用のトランジション*/{
transition: background 500ms ease;
}
#${SCRIPTNAME}-timetable-panel .channels .slot > *{
flex-shrink: 0;
pointer-events: none;/*e.targetをli.programに統一 & overflowしていても後続番組にhoverをゆずる*/
}
#${SCRIPTNAME}-timetable-panel .channels .hour > *:nth-child(1)/*時刻*/,
#${SCRIPTNAME}-timetable-panel .channels .program > *:nth-child(2)/*(通知ボタンの次の)時刻*/,
#${SCRIPTNAME}-timetable-panel .channels .program > *:nth-child(3)/*タイトルまたはmark*/{
margin-left: .25em;
}
#${SCRIPTNAME}-timetable-panel .channels > .time .hour:not(.nowonair) .time,
#${SCRIPTNAME}-timetable-panel .channels > .channel:not(:hover) .program:not(.active):not(.shown) .time{
color: ${configs.transparentGray};/*区切りとしての役割も果たす*/
}
/* 番組表 時刻列 */
#${SCRIPTNAME}-timetable-panel .channels > .time > header > button.now{
background: ${configs.nowOnAir_background};
text-align: right !important;
width: 100%;
padding-right: .25em;
overflow: hidden;
display: block;
transition: opacity 500ms ease;
}
#${SCRIPTNAME}-timetable-panel .channels > .time > header > button.now:hover,
#${SCRIPTNAME}-timetable-panel .channels .hour:hover{
filter: brightness(.5);
}
#${SCRIPTNAME}-timetable-panel .channels > .time > header > button.now .arrows{
display: inline-block;
transition: transform 500ms ease;
}
#${SCRIPTNAME}-timetable-panel .channels > .time > header > button.now.disabled{
opacity: .5;
}
#${SCRIPTNAME}-timetable-panel .channels > .time > header > button.now.disabled .arrows{
transform: translateX(1.25em);
pointer-events: none;
}
#${SCRIPTNAME}-timetable-panel .channels .times .hour:nth-child(2)/*clock*/{
padding-left: 1px;
}
#${SCRIPTNAME}-timetable-panel .channels .times .hour .clock .blink{
animation: blink 2s step-end infinite;
}
@keyframes blink{
50%{opacity: .5}
}
#${SCRIPTNAME}-timetable-panel .channels .day{
border-right: 1px solid ${configs.border_color};
height: ${configs.height}vh;
position: absolute;
overflow: hidden;
cursor: auto;
}
#${SCRIPTNAME}-timetable-panel .channels .day .date{
color: rgba(255,255,255,.125);
font-weight: bold;
font-size: ${configs.height}vh;
line-height: ${configs.height}vh;
}
#${SCRIPTNAME}-timetable-panel .channels .hour{
background: ${configs.times_background};
}
/* 番組表 個別番組 */
#${SCRIPTNAME}-timetable-panel .channels .program.hidden{
opacity: 0;
}
#${SCRIPTNAME}-timetable-panel .channels .program{
background: ${configs.comming_background};
}
#${SCRIPTNAME}-timetable-panel .channels .channel:not(:hover) .program:not(.nowonair):not(.active):not(.shown){
filter: brightness(.75);
}
#${SCRIPTNAME}-timetable-panel .channels .program > button,
#${SCRIPTNAME}-timetable-panel .channels .program > button *{
height: calc(${configs.rowheight}vh - 1px);/*borderぶん*/
}
#${SCRIPTNAME}-timetable-panel .channels .program > button{
padding: 0;
margin: 0 0 0 calc(-${configs.rowheight}vh + 1px);
opacity: 0;/*端数ピクセルがチラ見えしてしまうことがあるので*/
transition: margin 500ms ease, transform 250ms ease-in;
pointer-events: auto;
}
#${SCRIPTNAME}-timetable-panel .channels .program.shown > button{
margin-left: .25em;
opacity: 1;
}
#${SCRIPTNAME}-timetable-panel .channels .program > .mark,
#${SCRIPTNAME}-timetable-panel .channels .program > .mark *{
height: ${configs.fontsize}vh;
}
#${SCRIPTNAME}-timetable-panel .channels .program.nowonair{
background: ${configs.nowOnAir_background};
padding-left: ${BOUNCINGPIXEL}px;
}
#${SCRIPTNAME}-timetable-panel .channels .program.nowonair > button{
display: none;
}
#${SCRIPTNAME}-timetable-panel .channels .program > .time{
transition: max-width 1000ms ease, margin-left 1000ms ease, opacity 1000ms ease;
max-width: 3em;
overflow: hidden;
}
#${SCRIPTNAME}-timetable-panel .channels .program.nowonair > .time{
max-width: 0;
margin-left: 0;
opacity: 0;
}
#${SCRIPTNAME}-timetable-panel .channels .program.shown/*番組情報表示中の番組*/,
#${SCRIPTNAME}-timetable-panel .channels .channel:hover header,
#${SCRIPTNAME}-timetable-panel .channels .channel:hover .program{
background: ${configs.hover_background};
}
#${SCRIPTNAME}-timetable-panel .channels header:hover + .stream .program.nowonair,
#${SCRIPTNAME}-timetable-panel .channels .program:hover{
overflow: visible;
clip-path: inset(0 -100vw 0 0);/*overflowさせるのは右方向だけ*/
z-index: ${configs.program_zIndex};
}
#${SCRIPTNAME}-timetable-panel .channels .channel:hover header:not(:hover),
#${SCRIPTNAME}-timetable-panel .channels .channel:hover header:not(:hover) + .stream .program.nowonair:not(:hover),
#${SCRIPTNAME}-timetable-panel .channels .channel:hover .program:not(:hover):not(.nowonair)/*後続番組*/{
filter: brightness(.5);
}
#${SCRIPTNAME}-timetable-panel .channels header:hover + .stream .program:not(.nowonair) > */*後続番組の中身*/,
#${SCRIPTNAME}-timetable-panel .channels .channel:hover .program:not(:hover):not(.nowonair) > */*後続番組の中身*/{
opacity: .25;
}
html:not(.abemaTimetable) #${SCRIPTNAME}-timetable-panel .channels .channel.current header,
html:not(.abemaTimetable) #${SCRIPTNAME}-timetable-panel .channels .channel.current .program.nowonair,
#${SCRIPTNAME}-timetable-panel .channels .program.active{
background: ${configs.current_background} !important;
}
#${SCRIPTNAME}-timetable-panel .channels .channel:not(:hover) .program.nocontent{
background: ${configs.noContent_background};
filter: brightness(.75);
}
#${SCRIPTNAME}-timetable-panel .channels .channel:not(:hover) .program.nocontent .title{
opacity: .25;
}
#${SCRIPTNAME}-timetable-panel .channels .channel .program.padding/*空き枠*/{
cursor: auto;
}
/* 番組表スクローラ */
#${SCRIPTNAME}-timetable-panel .scrollers{
font-size: ${configs.fontsize}vh;/*emサイズ指定用*/
position: absolute;
bottom: 0;
left: ${NAMEWIDTH}vw;
width: ${100 - NAMEWIDTH}vw;
height: 100%;
z-index: ${configs.scrollers_zIndex};
pointer-events: none;
}
#${SCRIPTNAME}-timetable-panel .scrollers button.disabled{
pointer-events: none;
opacity: 0;
}
#${SCRIPTNAME}-timetable-panel .scrollers button{
background: ${configs.scroller_background};
border-radius: 5em;
width: 5em;
height: 5em;
transform: translateY(50%);
position: absolute;
bottom: 50%;
opacity: .25;
transition: opacity 500ms ease, right 500ms ease;
pointer-events: auto;
}
#${SCRIPTNAME}-timetable-panel .scrollers button:hover{
opacity: 1;
}
#${SCRIPTNAME}-timetable-panel .scrollers button.left{
left: .5em;
}
#${SCRIPTNAME}-timetable-panel .scrollers button.right{
right: .5em;
}
#${SCRIPTNAME}-timetable-panel .search.active ~ .scrollers button.right{
right: calc(${100*(1/3)}vw + .5em);
opacity: 1;
}
#${SCRIPTNAME}-timetable-panel .scrollers button > *{
width: 2em;
height: 2em;
vertical-align: middle;
opacity: .5;
}
/* 番組検索結果 */
#${SCRIPTNAME}-timetable-panel > .programs > .search{
background: ${configs.search_background};
border-top: 1px solid ${configs.border_color};
border-left: 1px solid ${configs.border_color};
width: calc(${100*(1/3)}vw + 1px + ${configs.scrollbarWidth}px);
height: 100%;
box-sizing: border-box;
margin-right: -${configs.scrollbarWidth}px;/*スクロールバーを隠す*/
-webkit-mask-image: linear-gradient(black 90%, rgba(0,0,0,.5));/*まだ-webkit取れない*/
mask-image: linear-gradient(black 90%, rgba(0,0,0,.5));
overflow-x: hidden;
overflow-y: scroll;
position: absolute;
top: 0;
right: 0;
z-index: ${configs.search_zIndex};
transform: translateX(100%);
transition: transform 500ms ease;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search.active,
#${SCRIPTNAME}-timetable-panel > .programs > .search:hover{
transform: translateX(0);
}
#${SCRIPTNAME}-timetable-panel > .programs > .search *{
font-size: ${configs.search_fontsize}vh;
line-height: ${configs.search_lineheight}vh;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .mark,
#${SCRIPTNAME}-timetable-panel > .programs > .search .mark *{
height: ${configs.search_fontsize}vh;
line-height: ${configs.search_fontsize}vh;
}
/* 番組検索結果 ヘッダ */
#${SCRIPTNAME}-timetable-panel > .programs > .search header{
display: block;
}
/* 番組検索結果 フィルタ */
#${SCRIPTNAME}-timetable-panel > .programs > .search .filters{
width: calc(100% - 2vh);
padding: 0;
margin: .25em 1vh;
border: 1px solid transparent;/*検索欄とツラ合わせしやすく*/
height: calc(1.92vh + .5em);
border-radius: .5em;
overflow: hidden;
display: flex;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .filters input{
display: none;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .filters label::before{
content: '✓';
color: black;
font-size: ${configs.search_lineheight}vh;
left: .1em;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .filters input:checked + label::before{
color: white;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .filters input + label{
color: ${configs.transparentGray};/*:not:checked*/
filter: brightness(.25);/*:not:checked*/
background: rgba(96,96,96,.75);
white-space: nowrap;
position: relative;
margin-left: 1px;
overflow: hidden;
flex: 1;
display: flex;
align-items: center;/*.markを中央揃え*/
cursor: pointer;
transition: background 500ms ease;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .filters input:checked + label{
color: white;
filter: brightness(1);
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .filters input + label:hover{
filter: brightness(.5);
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .filters input:checked + label:hover{
filter: brightness(.75);
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .filters input + label.notify{
background: ${configs.activeButton_color};
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .filters input + label > *{
flex-shrink: 0;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .filters input + label > .mark,
#${SCRIPTNAME}-timetable-panel > .programs > .search .filters input + label > .mark *{
font-size: ${(configs.search_fontsize + configs.search_lineheight) / 2}vh;
height: ${(configs.search_fontsize + configs.search_lineheight) / 2}vh;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .filters input + label:first-of-type{
margin-left: 0;
}
/* 番組検索結果 通知種別 */
#${SCRIPTNAME}-timetable-panel > .programs > .search .tabs{
margin: .25em 0;
display: flex;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .tabs > input{
display: none;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .tabs > input + label{
color: rgb(128,128,128);
padding: .25em 1vh;
border-bottom: 1px solid rgb(64,64,64);
flex: 1;
display: flex;
align-items: center;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .tabs > input:not(:checked) + label:hover{
color: rgb(192,192,192);
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .tabs > input:checked + label{
color: rgb(256,256,256);
border: 1px solid rgb(64,64,64);
border-bottom: none;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .tabs > input:checked + label:first-of-type{
border-left: none;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .tabs > input:checked + label:last-of-type{
border-right: none;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .tabs > input + label > svg{
fill: rgb(128,128,128);
height: ${(configs.search_fontsize + configs.search_lineheight) / 2}vh;
margin-right: .2em;
flex-shrink: 0;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .tabs > input:not(:checked) + label:hover > svg{
fill: rgb(192,192,192);
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .tabs > input:checked + label > svg{
fill: rgb(256,256,256);
}
/* 番組検索結果 サマリ(件数・検索通知) */
#${SCRIPTNAME}-timetable-panel > .programs > .search .summary{
padding: 0;
margin: .5em 1vh;
display: flex;
align-items: center;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .summary > *{
white-space: nowrap;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search .summary .count{
flex: 1;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search button.search_all{
text-align: left;
background: rgba(96,96,96,.75);
border: .1em solid transparent;
border-radius: 50vmax;
padding: .15em calc(${configs.search_fontsize / 2}vh - 1px);
margin: 0;
flex-shrink: 0;
display: flex;
align-items: center;/*.markを中央揃え*/
transition: transform 250ms ease-in;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search button.search_all:hover{
background: rgba(96,96,96,.25);
filter: brightness(1);/*打ち消し*/
}
#${SCRIPTNAME}-timetable-panel > .programs > .search button.search_all.reversing{
transform: scaleY(0);
transition: transform 250ms ease-out;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search button.search_all.active{
background: ${configs.activeButton_color};
border: .1em solid white;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search button.search_all > *{
margin-right: .25em;
transform: scaleX(1);
flex-shrink: 0;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search button.search_all > svg:first-child/*検索アイコン*/{
fill: white;
width: calc(${configs.search_fontsize}vh + .25em);/*膨れさせる*/
height: calc(${configs.search_fontsize}vh + .25em);/*膨れさせる*/
}
#${SCRIPTNAME}-timetable-panel > .programs > .search button.search_all .mark{
margin-left: .125em;
margin-right: .125em;
}
/* 番組検索結果 番組リスト */
#${SCRIPTNAME}-timetable-panel > .programs > .search ul{
padding: 0;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search li.program{
height: calc(${configs.search_fontsize + configs.search_lineheight}vh);/*この高さがh2,間隙,.dataの高さになる*/
padding: 0;
margin: .5vh 1vh 1vh 1vh;
display: grid;
grid-template-columns: calc(${configs.search_lineheight * 2 * (16/9)}vh) 1fr;
grid-template-rows: ${configs.search_lineheight}vh ${configs.search_lineheight}vh;
height: ${configs.search_lineheight * 2}vh;
cursor: pointer;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search li.program:hover{
filter: brightness(.75);
}
#${SCRIPTNAME}-timetable-panel > .programs > .search li.program > .thumbnail{
width: calc(${configs.search_lineheight * 2 * (16/9)}vh);
height: calc(${configs.search_lineheight * 2}vh);
padding: 0;
grid-column: 1;
grid-row: 1 / 2;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search li.program > .thumbnail img{
display: block;
width: auto;/*4:3もある*/
max-width: calc(${configs.search_lineheight * 2 * (16/9)}vh - 1px);/*端数処理ではみ出すのを防ぐ*/
height: calc(${configs.search_lineheight * 2}vh);
margin: 0 auto;
transition: opacity 500ms ease;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search li.program > .thumbnail img.loading{
opacity: 0;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search li.program > h2,
#${SCRIPTNAME}-timetable-panel > .programs > .search li.program > .data{
white-space: nowrap;
padding: 0 1vh;
display: flex;
align-items: center;/*.markを中央揃え*/
}
#${SCRIPTNAME}-timetable-panel > .programs > .search li.program > h2 > *,
#${SCRIPTNAME}-timetable-panel > .programs > .search li.program > .data > *{
flex-shrink: 0;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search li.program > h2{
height: ${(configs.search_lineheight + configs.search_fontsize) / 2}vh;/*ほどよく切り詰める*/
grid-column: 2;
grid-row: 1;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search li.program > h2 .mark,
#${SCRIPTNAME}-timetable-panel > .programs > .search li.program > h2 .mark *{
height: ${configs.search_fontsize}vh;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search li.program > h2 .title{
vertical-align: middle;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search li.program > .data{
color: ${configs.transparentGray};
vertical-align: middle;
grid-column: 2;
grid-row: 2;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search li.program > .data button,
#${SCRIPTNAME}-timetable-panel > .programs > .search li.program > .data button > *{
font-size: ${configs.fontsize}vh;/*emサイズを合わせる*/
height: ${configs.search_lineheight}vh;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search li.program > .data button + *{
margin-left: .25em;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search li.program > .data button + * ~ *{
margin-left: 1em;
}
/* 番組検索結果 番組リスト(日付別・毎回通知・検索通知共通) */
#${SCRIPTNAME}-timetable-panel > .programs > .search li.day,
#${SCRIPTNAME}-timetable-panel > .programs > .search li.repeat,
#${SCRIPTNAME}-timetable-panel > .programs > .search li.search{
padding: 0;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search li.day > h2,
#${SCRIPTNAME}-timetable-panel > .programs > .search li.repeat > h2,
#${SCRIPTNAME}-timetable-panel > .programs > .search li.search > h2{
padding-left: 1vh;
white-space: nowrap;
display: flex;
align-items: center;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search li.repeat > h2 button + *,
#${SCRIPTNAME}-timetable-panel > .programs > .search li.search > h2 button + *{
margin-left: .25em;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search li.noprogram{
color: gray;
padding-left: 1vh;
}
/* 番組検索結果 番組リスト(毎回通知) */
#${SCRIPTNAME}-timetable-panel > .programs > .search li.repeat > h2 button,
#${SCRIPTNAME}-timetable-panel > .programs > .search li.repeat > h2 button > *{
height: ${configs.search_fontsize}vh;
}
/* 番組検索結果 番組リスト(検索通知) */
#${SCRIPTNAME}-timetable-panel > .programs > .search li.search > h2 button,
#${SCRIPTNAME}-timetable-panel > .programs > .search li.search > h2 button > *{
height: ${configs.search_lineheight}vh;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search li.search > h2 button > *{
fill: ${configs.activeButton_color};
background: transparent;
border: none;
filter: brightness(1.25);
}
#${SCRIPTNAME}-timetable-panel > .programs > .search li.search > h2 .key{
display: flex;
align-items: center;
}
#${SCRIPTNAME}-timetable-panel > .programs > .search li.search > h2 .key .mark{
margin-left: .125em;
margin-right: .125em;
}
/* 設定パネル */
#${SCRIPTNAME}-config-panel{
width: 360px;
}
#${SCRIPTNAME}-config-panel fieldset p,
#${SCRIPTNAME}-config-panel fieldset li{
padding-left: calc(10px + 1em);
}
#${SCRIPTNAME}-config-panel fieldset .sub{
padding-left: calc(10px + 2em);
}
#${SCRIPTNAME}-config-panel fieldset p:hover,
#${SCRIPTNAME}-config-panel fieldset li:hover{
background: rgba(255,255,255,.25);
}
#${SCRIPTNAME}-config-panel fieldset p.disabled,
#${SCRIPTNAME}-config-panel fieldset li.disabled{
opacity: .5;
}
#${SCRIPTNAME}-config-panel label{
display: block;
}
#${SCRIPTNAME}-config-panel input{
width: 80px;
height: 20px;
position: absolute;
right: 10px;
}
#${SCRIPTNAME}-config-panel fieldset ul.channels{
columns: 2;
column-gap: 0;
}
#${SCRIPTNAME}-config-panel fieldset ul.channels li input{
width: 20px;
vertical-align: bottom;
margin-right: .25em;
position: static;
}
</style>
`,
},
};
if(!('animate' in HTMLElement.prototype)) HTMLElement.prototype.animate = function(){};
if(!('isConnected' in Node.prototype)) Node.prototype.isConnected = true;
class Storage{
static key(key){
return (SCRIPTNAME) ? (SCRIPTNAME + '-' + key) : key;
}
static save(key, value, expire = null){
key = Storage.key(key);
localStorage[key] = JSON.stringify({
expire: expire,
value: value,
});
}
static read(key){
key = Storage.key(key);
if(localStorage[key] === undefined) return undefined;
let data = JSON.parse(localStorage[key]);
if(data.value === undefined) return data;
if(data.expire === undefined) return data;
if(data.expire === null) return data.value;
if(data.expire < Date.now()) return localStorage.removeItem(key);
return data.value;
}
static delete(key){
key = Storage.key(key);
delete localStorage[key];
}
}
let $ = function(s){return document.querySelector(s)};
let $$ = function(s){return document.querySelectorAll(s)};
let animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))};
let sequence = function(){
let chain = [], defer = function(callback, delay, ...params){(delay) ? setTimeout(callback, delay, ...params) : animate(callback, ...params)};
for(let i = arguments.length - 1, delay = 0; 0 <= i; i--, delay = 0){
if(typeof arguments[i] === 'function'){
for(let j = i - 1; typeof arguments[j] === 'number'; j--) delay += arguments[j];
let f = arguments[i], d = delay, callback = chain[chain.length - 1];
chain.push(function(pass){defer(function(ch){ch ? ch(f(pass)) : f(pass);}, d, callback)});/*nearly black magic*/
}
}
chain[chain.length - 1]();
};
let observe = function(element, callback, options = {childList: true, attributes: false, characterData: false}){
let observer = new MutationObserver(callback.bind(element));
observer.observe(element, options);
return observer;
};
let createElement = function(html){
let outer = document.createElement('div');
outer.innerHTML = html;
return outer.firstElementChild;
};
let getScrollbarWidth = function(){
let div = document.createElement('div');
div.style.height = '1px';
document.body.appendChild(div);
div.style.overflowY = 'scroll';
let clientWidth = div.clientWidth;
div.style.overflowY = 'hidden';
let offsetWidth = div.offsetWidth;
document.body.removeChild(div);
return offsetWidth - clientWidth;
};
let normalize = function(string){
return string.trim().replace(/[!-}]/g, function(s){
return String.fromCharCode(s.charCodeAt(0) - 0xFEE0);
}).replace(/ /g, ' ').replace(/~/g, '〜');
};
let linkify = function(node){
split(node);
function split(n){
if(['style', 'script', 'a'].includes(n.localName)) return;
if(n.nodeType === Node.TEXT_NODE){
let pos = n.data.search(linkify.RE);
if(0 <= pos){
let target = n.splitText(pos);/*pos直前までのnとpos以降のtargetに分割*/
let rest = target.splitText(RegExp.lastMatch.length);/*targetと続くrestに分割*/
/* この時点でn(処理済み),target(リンクテキスト),rest(次に処理)の3つに分割されている */
let a = document.createElement('a');
let match = target.data.match(linkify.RE);
switch(true){
case(match[1] !== undefined): a.href = (match[1][0] == 'h') ? match[1] : 'h' + match[1]; break;
case(match[2] !== undefined): a.href = 'http://' + match[2]; break;
case(match[3] !== undefined): a.href = 'mailto:' + match[4] + '@' + match[5]; break;
}
a.appendChild(target);/*textContent*/
rest.parentNode.insertBefore(a, rest);
}
}else{
for(let i = 0; n.childNodes[i]; i++) split(n.childNodes[i]);/*回しながらchildNodesは増えていく*/
}
}
};
linkify.RE = new RegExp([
'(h?ttps?://[-\\w_./~*%$@:;,!?&=+#]+[-\\w_/~*%$@:;&=+#])',/*通常のURL*/
'((?:\\w+\\.)+\\w+/[-\\w_./~*%$@:;,!?&=+#]*)',/*http://の省略形*/
'((\\w[-\\w_.]+)(?:@|@)(\\w[-\\w_.]+\\w))',/*メールアドレス*/
].join('|'));
let log = function(){
if(!DEBUG) return;
let l = log.last = log.now || new Date(), n = log.now = new Date();
let stack = new Error().stack, callers = stack.match(/^([^/<]+(?=<?@))/gm) || stack.match(/[^. ]+(?= \(<anonymous)/gm) || [];
console.log(
SCRIPTNAME + ':',
/* 00:00:00.000 */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3),
/* +0.000s */ '+' + ((n-l)/1000).toFixed(3) + 's',
/* :00 */ ':' + stack.match(/:[0-9]+:[0-9]+/g)[1].split(':')[1],/*LINE*/
/* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') +
/* caller */ (callers[1] || '') + '()',
...arguments
);
};
core.initialize();
if(window === top && console.timeEnd) console.timeEnd(SCRIPTNAME);
})();