ABEMA Little Tools

画質変更やNGワードなど、ABEMAをちょっとだけ便利にするかもしれない機能をまとめました。

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         ABEMA Little Tools
// @namespace    https://greasyfork.org/ja/scripts/465585
// @version      18
// @description  画質変更やNGワードなど、ABEMAをちょっとだけ便利にするかもしれない機能をまとめました。
// @match        https://abema.tv/*
// @grant        GM_registerMenuCommand
// @license      MIT License
// @noframes
// @require      https://unpkg.com/dexie@3/dist/dexie.min.js
// ==/UserScript==

(() => {
  'use strict';
  const sid = 'LittleTools';
  const dpid = 'DataProvider';
  const EVENTS = {
    BROADCAST_DATA_UPDATED: `${sid}:broadcastDataUpdated`,
    CHANNELS_UPDATED: `${sid}:channelsUpdated`,
    REQUEST_FETCH_BROADCAST_DATA: `${sid}:requestFetchBroadcastData`,
    STATS_UPDATED: `${sid}:statsUpdated`,
    DELETE_DATABASE: `${sid}:deleteDatabase`,
  };
  const RUNNING_KEY = `${sid}_${dpid}_Running`;
  let mainElement = null;
  let db = null;
  const Dexie = /** @type {any} */ (window).Dexie;

  /**
   * @typedef {Object} BroadcastSlot 配信データ
   * @property {string} id 番組ID
   * @property {string} channelId チャンネルID
   * @property {string} [title] 番組タイトル
   * @property {number} [startAt] 開始時刻(unix)
   * @property {number} [endAt] 終了時刻(unix)
   * @property {Object} [thumbnails] サムネイル情報
   * @property {Object} [thumbnails.default]
   * @property {string} [thumbnails.default.id]
   * @property {string} [thumbnails.default.name]
   * @property {string} [thumbnails.default.version]
   */

  /**
   * @typedef {Object} TimetableSlot 番組表データ
   * @property {string} id 番組ID
   * @property {string} channelId チャンネルID
   * @property {string} title 番組タイトル
   * @property {number} startAt 開始時刻(unix)
   * @property {number} endAt 終了時刻(unix)
   * @property {string} [highlight] 番組の見どころ
   * @property {number} [timeshiftFreeEndAt] 無料見逃し視聴終了時刻(unix)
   * @property {number} [timeshiftEndAt] プレミアム見逃し視聴終了時刻(unix)
   * @property {Object} [thumbnails] サムネイル情報
   * @property {Object} [thumbnails.default]
   * @property {string} [thumbnails.default.id]
   * @property {string} [thumbnails.default.name]
   * @property {string} [thumbnails.default.version]
   * @property {Object} [mark]
   * @property {boolean} [mark.newcomer] 新着
   * @property {boolean} [mark.bingeWatching] 一挙
   * @property {boolean} [mark.recommendation] 注目
   * @property {boolean} [mark.live] 生
   * @property {boolean} [mark.first] 初回
   * @property {boolean} [mark.last] 最終回
   * @property {string[]} [labels] ラベル
   */

  /**
   * 通知キュー
   * @type {{details: {id: string, hasProgram: boolean, program?: {id: string, title: string, startAt: number, endAt: number, tId?: string, tName?: string, tVersion?: string}}[], headerText: string}[]}
   */
  const notificationQueue = [];
  let isNotificationShowing = false;

  /**
   * localStorageからデータを安全に取得する
   * @param {string} key
   * @returns {Object<string, any>}
   */
  const getLS = (key) => {
    try {
      return JSON.parse(localStorage.getItem(key) || '{}') || {};
    } catch (e) {
      log(`${sid}: Failed to parse localStorage for ${key}`, e, 'error');
      return {};
    }
  };

  /** @type {Object<string, any>} */
  const ls = getLS(sid);
  /** @type {Object<string, any>} */
  const lsWord = getLS(`${sid}-Word`);
  /** @type {Object<string, any>} */
  const lsId = getLS(`${sid}-Id`);

  /**
   * スクリプト内の共通データ
   */
  const data = {
    /** @type {{createdAtMs: number, elapsedMs: number, id: string, isOwner: boolean, message: string, userId: string}[]} */
    archiveComments: [
      {
        createdAtMs: 0,
        elapsedMs: 0,
        id: '',
        isOwner: false,
        message: '',
        userId: '',
      },
    ],
    blockedUserId: '',
    /** @type {BroadcastSlot[]} */
    broadcastSlots: [],
    /** @type {string[]} */
    channelId: [],
    /** @type {Object<string, string>} */
    channels: {},
    /** @type {{userid: string, message: string[]}[]} */
    comment: [{ userid: '', message: [''] }],
    commentAll: 0,
    /** @type {Set<string>} */
    commentId: new Set(),
    commentMouseEnter: false,
    dataProviderRunning: false,
    footerFeed: '',
    href: '',
    imageDomain: '',
    lastBroadcastFetchTime: 0,
    newComments: false,
    /** @type {{channelId: string, current: TimetableSlot | null, programs: {title: string, startAt: number, endAt: number}[]}[]} */
    nextPrograms: [],
    /** @type {Set<string>} */
    ngId: new Set(lsId.ngId),
    /** @type {string[]} */
    ngWordText: [],
    /** @type {RegExp[]} */
    ngWordRe: [],
    /** @type {RegExp[]} */
    ngWordWarningRe: lsWord.warningRe ? [...lsWord.warningRe] : [],
    /** @type {{slot?: BroadcastSlot|TimetableSlot}} */
    program: {},
    programId: '',
    /** @type {string[]} */
    searchHistory: [],
    showVideoResolution: false,
    /** @type {{slotId: string, view: number, comment: number}} */
    stats: { slotId: '', view: 0, comment: 0 },
    statsDomain: '',
    title: '',
    version: 18,
    videoSource: '',
  };

  /**
   * 定期実行(タイマー)のIDを管理
   */
  const interval = {
    changePageTitle: 0,
    changeTargetQuality: 0,
    checkSwitchedProgram: 0,
    comment: 0,
    init: 0,
    navigation: 0,
    newcomment: 0,
    nextPrograms: 0,
    notification: 0,
    programInfo2: 0,
    resizeVideo: 0,
    resolution: 0,
    videoelement: 0,
    videoskip: 0,
    videosource: 0,
  };

  /**
   * DOM要素を選択するためのCSSセレクタ
   */
  const selector = {
    archiveCommentContainer: 'c-archive-comment-ArchiveCommentContainerView',
    commentBefore: `:is(.com-tv-CommentBlock, .com-comment-CommentItem):has(> div:not([data-${sid.toLowerCase()}-hidden])), .com-archive-comment-ArchiveCommentItem:has(> p:not([data-${sid.toLowerCase()}-hidden]))`,
    commentDuplicate: `:is(.com-tv-CommentBlock, .com-comment-CommentItem):has(> div[data-${sid.toLowerCase()}-duplicate]), .com-archive-comment-ArchiveCommentItem:has(> p[data-${sid.toLowerCase()}-duplicate])`,
    commentHidden: `:is(.com-tv-CommentBlock, .com-comment-CommentItem):has(> div[data-${sid.toLowerCase()}-hidden="true"]), .com-archive-comment-ArchiveCommentItem:has(> p[data-${sid.toLowerCase()}-hidden="true"])`,
    commentAll:
      '.com-tv-CommentBlock, .com-archive-comment-ArchiveCommentItem, .com-comment-CommentItem',
    commentArea:
      '.com-tv-CommentArea, .c-tv-TimeshiftPlayerContainerView__comment-wrapper:has(.com-a-OnReachTop > ul), .com-comment-CommentContainerView',
    commentButton: 'button:has(svg[aria-label^="コメント"])',
    commentCounter: '.com-a-Counter',
    commentContinue:
      '.com-tv-CommentArea__continue-button, .c-archive-comment-ArchiveCommentContainerView__new-comment-button, .com-comment-CommentContinueButton',
    commentForm:
      '.com-o-CommentForm__opened-textarea, .com-comment-CommentTextarea__textarea',
    commentInner:
      '.com-tv-CommentBlock__inner, .com-archive-comment-ArchiveCommentItem__message, .com-comment-CommentItem__inner',
    commentInnerTs: `.com-archive-comment-ArchiveCommentItem__message:not([data-${sid.toLowerCase()}-user-id])`,
    commentList:
      '.com-a-OnReachTop > :is(div, ul), .com-comment-CommentList__inner > ul',
    commentMessage:
      '.com-tv-CommentBlock__message > span, .com-archive-comment-ArchiveCommentItem__message > span, .com-comment-CommentItem__body',
    commentReport: `.com-tv-CommentReportForm:not([data-${sid.toLowerCase()}-commentreportform]), .com-archive-comment-ArchiveCommentReportForm:not([data-${sid.toLowerCase()}-commentreportform]), .com-comment-CommentReportForm:not([data-${sid.toLowerCase()}-commentreportform])`,
    commentReport2: `.com-tv-CommentReportForm[data-${sid.toLowerCase()}-commentreportform], .com-archive-comment-ArchiveCommentReportForm[data-${sid.toLowerCase()}-commentreportform], .com-comment-CommentReportForm[data-${sid.toLowerCase()}-commentreportform]`,
    commentReportCancel:
      '.com-tv-CommentReportForm__cancel-button, .com-archive-comment-ArchiveCommentReportForm__cancel-button, .com-comment-CommentReportForm__cancel-button',
    commentReportSubmitLe: 'com-comment-CommentReportForm__submit-button',
    commentReportSubmitTs:
      'com-archive-comment-ArchiveCommentReportForm__submit-button',
    commentReportSubmitTv: 'com-tv-CommentReportForm__submit-button',
    commentReportLe: '.com-comment-CommentReportForm',
    commentReportTs: '.com-archive-comment-ArchiveCommentReportForm',
    commentReportTv: '.com-tv-CommentReportForm',
    commentTextarea: '.com-o-CommentForm__opened-textarea',
    commentTs: `.com-archive-comment-ArchiveCommentItem:has(.com-archive-comment-ArchiveCommentItem__message:not([data-${sid.toLowerCase()}-user-id]))`,
    footer:
      '.com-tv-LinearFooter,.com-vod-VideoControlBar,.com-live-event-LiveEventVideoController,.com-vod-LiveEventPayperviewControlBar',
    footerFeed: '.com-tv-LinearFooter__feed-super-text',
    footerVisible:
      '.com-tv-TVScreen__footer-container:not(.com-tv-TVScreen__footer-container--hidden),.com-vod-VODScreen-container:not(.com-vod-VODScreen-container--cursor-hidden),.com-live-event-LiveEventPlayerAreaLayout--controllers-visible',
    headerMenuButton: '.com-m-side-nav-toggle-button',
    inner: '.c-application-DesktopAppContainer__content',
    main: '#main',
    mypageMenu: '.com-application-MypageMenu__menu',
    nextCancel: '.com-vod-VODNextProgramInfo__cancel-button',
    nextCancelMini:
      '.com-vod-VODScreenOverlayForMiniPlayer__cancel-next-program-button',
    notification: '.com-m-NotificationManager',
    notificationClose: '.com-m-Notification__button[aria-label="閉じる"]',
    notificationMessage: '.com-m-Notification__message span',
    programDetailButton: '.com-tv-LinearFooterProgramDetailButton',
    recommendedCancel:
      '.com-vod-VODFirstProgramOfRecommendedSeriesInfo__cancel-button',
    searchForm: 'form[action="/search"]',
    searchInput: 'input[name="q"]',
    searchSuggestItemButton:
      '.com-search-SearchSuggestListItem__button:not([data-littletools-suggest])',
    sideNavi: '.com-application-SideNavigation',
    sideNaviClosed: 'com-application-SideNavigation--closed',
    sideNaviWrapClosed: 'com-application-SideNavigation__wrapper--closed',
    sidePanelClose:
      '.com-tv-FeedSidePanel__close-button,.com-live-event-LiveEventStatsSidePanel__close,.com-comment-CommentAreaHeader__close-button',
    tvChannelListItem: '.com-tv-LinearChannelListItem',
    tvChannelListItemInner: '.com-tv-LinearChannelListItem__inner',
    tvChannelListShrunk: '.com-tv-LinearChannelList--shrunk',
    tvContainerScreen: '.c-tv-NowOnAirContainer__screen',
    tvScreen: '.com-tv-TVScreen',
    tvScreenOverlay: '.com-tv-TVScreen__overlay',
    video: 'video[src]:not([style*="display: none;"])',
    videoContainer:
      '.com-a-Video__container, .com-live-event__LiveEventPlayerView',
    videoDblclick:
      '.com-vod-VideoControlBar,.c-vod-EpisodePlayerContainer-ad-container,.c-tv-TimeshiftPlayerContainerView__ad-container,.com-live-event-LiveEventPlayerSectionLayout__player-area',
    videoMainPlayer:
      '.com-vod-VODMiniPlayerWrapper__player:not(.com-vod-VODMiniPlayerWrapper__player--bg):not(.com-vod-VODMiniPlayerWrapper__player--mini),.com-live-event-LiveEventPlayerAreaLayout__player',
    videoSkip: '.com-video_ad-AdSkipButton',
    videoSkip2: '.com-video_ad-AdSkipButton:not([disabled])',
  };

  /**
   * スクリプトの設定値
   * @type {{
   * _ngid: number[],
   * reduceNavigation: boolean, mouseoverNavigation: boolean, closeNotification: boolean, videoResolution: boolean,
   * headerPosition: boolean, semiTransparent: boolean, smallFontSize: boolean, overlapSidePanel: boolean,
   * sidePanelBackground: boolean, sidePanelSize: boolean, sidePanelSizeNum: number|string, hiddenIdAndPlan: boolean,
   * notifyNewChannel: boolean, notifyNewChannelTarget: number|string,
   * closeSidePanel: boolean, sidePanelCloseButton: boolean, showProgramDetail: boolean, hiddenButtonText: boolean,
   * viewCounter: boolean, programInfo1: boolean, programInfo2: boolean, programInfo2Num: number|string,
   * nextPrograms: boolean, nextProgramsNum: number|string, searchProgram: boolean,
   * nextProgramInfo: boolean, recommendedSeriesInfo: boolean, skipVideo: boolean,
   * videoPadding: boolean, dblclickScroll: boolean,
   * newCommentOneByOne: boolean, scrollNewComment: boolean, stopCommentScroll: boolean,
   * highlightNewComment: boolean, highlightFirstComment: boolean, commentFontSize: boolean, commentFontSizeNum: number|string,
   * reduceCommentSpace: boolean, hiddenCommentList: boolean, hiddenCommentListNum: number|string, hiddenCommentListNum2: number|string,
   * escKey: boolean, enterKey: boolean, reportFormCommentList: boolean,
   * qualityEnable: boolean, targetQuality: number,
   * ngWordEnable: boolean, ngWord: string, ngConsole: boolean,
   * ngIdEnable: boolean, ngId: string[], ngIdMaxSize: number
   * }}
   */
  const setting = {
    _ngid: [0, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000],
    // 全般
    reduceNavigation: ls.reduceNavigation,
    mouseoverNavigation: ls.mouseoverNavigation,
    closeNotification: ls.closeNotification,
    videoResolution: ls.videoResolution,
    headerPosition: ls.headerPosition,
    semiTransparent: ls.semiTransparent,
    smallFontSize: ls.smallFontSize,
    overlapSidePanel: ls.overlapSidePanel,
    sidePanelBackground: ls.sidePanelBackground,
    sidePanelSize: ls.sidePanelSize,
    sidePanelSizeNum: ls.sidePanelSizeNum,
    hiddenIdAndPlan: ls.hiddenIdAndPlan,
    notifyNewChannel: ls.notifyNewChannel,
    notifyNewChannelTarget: ls.notifyNewChannelTarget,
    // テレビ
    closeSidePanel: ls.closeSidePanel,
    sidePanelCloseButton: ls.sidePanelCloseButton,
    showProgramDetail: ls.showProgramDetail,
    hiddenButtonText: ls.hiddenButtonText,
    viewCounter: ls.viewCounter,
    programInfo1: ls.programInfo1,
    programInfo2: ls.programInfo2,
    programInfo2Num: ls.programInfo2Num,
    nextPrograms: ls.nextPrograms,
    nextProgramsNum: ls.nextProgramsNum,
    searchProgram: ls.searchProgram,
    // ビデオ・見逃し視聴
    nextProgramInfo: ls.nextProgramInfo,
    recommendedSeriesInfo: ls.recommendedSeriesInfo,
    skipVideo: ls.skipVideo,
    // ビデオ・見逃し視聴・ライブイベント
    videoPadding: ls.videoPadding,
    dblclickScroll: ls.dblclickScroll,
    // コメント
    newCommentOneByOne: ls.newCommentOneByOne,
    scrollNewComment: ls.scrollNewComment,
    stopCommentScroll: ls.stopCommentScroll,
    highlightNewComment: ls.highlightNewComment,
    highlightFirstComment: ls.highlightFirstComment,
    commentFontSize: ls.commentFontSize,
    commentFontSizeNum: ls.commentFontSizeNum,
    reduceCommentSpace: ls.reduceCommentSpace,
    hiddenCommentList: ls.hiddenCommentList,
    hiddenCommentListNum: ls.hiddenCommentListNum,
    hiddenCommentListNum2: ls.hiddenCommentListNum2,
    escKey: ls.escKey,
    enterKey: ls.enterKey,
    reportFormCommentList: ls.reportFormCommentList,
    // 画質
    qualityEnable: ls.qualityEnable,
    targetQuality: ls.targetQuality,
    // NGワード
    ngWordEnable: ls.ngWordEnable,
    ngWord: lsWord.ngWord,
    ngConsole: ls.ngConsole,
    // NG ID
    ngIdEnable: ls.ngIdEnable,
    ngId: lsId.ngId ? [...lsId.ngId] : [],
    ngIdMaxSize: ls.ngIdMaxSize,
  };

  /**
   * ビデオ要素の状態管理
   */
  const video = {
    clientHeight: 0,
    clientWidth: 0,
    maxHeight: 0,
    pixelRatio: 0,
    src: '',
    videoHeight: 0,
    videoWidth: 0,
  };

  /**
   * NG IDを追加する
   */
  const addNgId = () => {
    log('addNgId');
    clearInterval(interval.newcomment);
    const blocked = checkBlockedUser(false);
    if (
      blocked >= 100 &&
      setting.ngIdMaxSize &&
      setting._ngid[setting.ngIdMaxSize] > data.ngId.size &&
      data.blockedUserId &&
      !data.ngId.has(data.blockedUserId)
    ) {
      setting.ngId.push(data.blockedUserId);
      lsId.ngId.push(data.blockedUserId);
      data.ngId.add(data.blockedUserId);
      log('addNgId', data.blockedUserId, data.ngId.size);
      saveStorage();
      setTimeout(() => {
        checkBlockedUser(true);
      }, 1000);
    } else {
      log(
        'not add NGiD',
        blocked,
        setting.ngIdMaxSize,
        setting._ngid[setting.ngIdMaxSize],
        data.ngId.size,
        data.blockedUserId,
        data.ngId.has(data.blockedUserId),
      );
    }
  };

  /**
   * スタイルを追加
   * @param {string} s
   */
  const addStyle = (s) => {
    const init = `
:root {
  --${sid}-pi-font-size: 14px;
  --${sid}-pi-title-size: clamp(1rem, 0.8rem + 1cqw, 2rem);
  --${sid}-pi2-font-shadow: calc(var(--${sid}-pi-font-size) / 2);
  --${sid}-pi2-title-shadow: calc(var(--${sid}-pi-title-size) / 2);
}
:is(.com-tv-CommentBlock, .com-comment-CommentItem):has(
  > div[data-${sid.toLowerCase()}-hidden="true"],
  > div[data-${sid.toLowerCase()}-ngword],
  > div[data-${sid.toLowerCase()}-ngid]
),
.com-archive-comment-ArchiveCommentItem:has(
  > p[data-${sid.toLowerCase()}-hidden="true"],
  > p[data-${sid.toLowerCase()}-ngword],
  > p[data-${sid.toLowerCase()}-ngid]
),
.${sid}_ProgramInfo_hidden,
.${sid}_Settings_hidden,
.${sid}_Settings-tab-switch {
  display: none !important;
}
#${sid}_Settings {
  background-color: #F9FCFF;
  border: 2px solid #CCCCCC;
  border-radius: 8px;
  box-shadow: 4px 4px 16px rgba(0,0,0,0.5);
  color: black;
  left: 20px;
  max-height: calc(100vh - 40px);
  max-width: calc(100vw - 40px);
  min-width: 45em;
  overflow: auto;
  padding: 8px;
  position: fixed;
  top: 20px;
  user-select: none;
  width: min-content;
  z-index: 9900;
  label[title] {
    cursor: help;
  }
  input[type="number"],
  select {
    margin-left: 8px;
    margin-right: 2px;
  }
}
#main:has(.c-tv-NowOnAirContainer__side-panel--shown) ~ #${sid}_Settings {
  max-width: calc(100vw - 460px);
}
#${sid}_Settings-header {
  position: relative;
  text-align: center;
}
#${sid}_Settings-header-title a {
  text-decoration: none;
  &:hover {
    text-decoration: underline;
  }
}
#${sid}_Settings-header-close {
  background: none;
  border: none;
  color: #888;
  cursor: pointer;
  font-size: 1.5rem;
  line-height: 1;
  padding: 4px;
  position: absolute;
  right: -6px;
  top: -6px;
  &:hover {
    color: black;
  }
}
#${sid}_Settings-main {
  display: flex;
  flex-wrap: wrap;
  margin: 8px 0;
  &::after {
    background: #8899aa;
    content: '';
    display: block;
    height: 1px;
    order: -1;
    width: 100%;
  }
  fieldset {
    border: 1px solid #CCCCCC;
    margin: 2px 0;
    padding: 4px 8px;
  }
  fieldset + label {
    margin-top: 2px;
  }
  fieldset + fieldset {
    margin-top: 10px;
  }
  pre {
    background-color: #FFFFEE;
    border: 1px solid #DDDDDD;
    margin-left: 1em;
    padding: 4px;
    user-select: text;
    width: min-content;
  }
  :is(label, details):not(.${sid}_Settings-tab-label) {
    display: inline-block;
    width: 100%;
  }
  :is(label, details):not(.${sid}_Settings-tab-label):hover {
    background-color: #E3ECF6;
  }
  details {
    transition: 0.5s;
  }
  input[type="checkbox"] {
    position: relative;
    top: 2px;
    margin-right: 4px;
  }
  summary {
    cursor: pointer;
    display: list-item;
    &::before {
      background: #88AA88;
      border-radius: 50%;
      color: #FFFFFF;
      content: "?";
      display: inline-block;
      font-size: 85%;
      font-weight: bold;
      height: 1.5em;
      line-height: 1.5;
      margin-right: 4px;
      text-align: center;
      vertical-align: 2px;
      width: 1.5em;
    }
  }
  select {
    appearance: auto;
    cursor: pointer;
    margin-top: 4px;
    margin-bottom: 4px;
    padding: 4px 8px;
  }
  input + input,
  input + input + input,
  legend:has(input),
  legend:has(input) ~ label {
    color: gray;
  }
  input:checked + input,
  input:checked + input + input,
  legend:has(input:checked),
  legend:has(input:checked) ~ label {
    color: black;
  }
  input + input,
  input + input + input,
  legend:has(input):has(
    ~ #${sid}_Settings-ngWord,
    ~ label #${sid}_Settings-ngIdMaxSize,
    ~ label #${sid}_Settings-targetQuality
  ) {
    background-color: #DDDDDD;
  }
  input:checked + input,
  input:checked + input + input,
  legend:has(input:checked):has(
    ~ #${sid}_Settings-ngWord,
    ~ label #${sid}_Settings-ngIdMaxSize,
    ~ label #${sid}_Settings-targetQuality
  ) {
    background-color: white;
  }
}
#${sid}_Settings-commentFontSizeNum,
#${sid}_Settings-programInfo2Num {
  text-align: center;
  width: 3.5em;
}
#${sid}_Settings-hiddenCommentListNum,
#${sid}_Settings-hiddenCommentListNum2 {
  text-align: center;
  width: 4em;
}
#${sid}_Settings-sidePanelSizeNum {
  text-align: center;
  width: 5em;
}
#${sid}_Settings-ngWord {
  height: 6em;
  margin-top: 8px;
  max-width: calc(100vw - 118px);
  min-height: 6em;
  min-width: 40em;
  width: 100%;
}
#${sid}_Settings-ngWord-error,
#${sid}_Settings-ngWord-warning {
  display: none;
  margin-bottom: 4px;
  pre {
    font-weight: bold;
    margin-top: 4px;
    overflow: auto;
    padding: 4px 8px;
    white-space: pre-wrap;
    width: 550px;
  }
}
#${sid}_Settings-ngWord-error p {
  color: red;
}
#${sid}_Settings-ngWord-warning p {
  color: orange;
}
#${sid}_Settings-ngIdMaxSize {
  text-align: right;
}
#${sid}_Settings-ngId-record {
  margin-left: 1em;
}
#${sid}_Settings-footer-notice {
  display: none;
  background-color: #FFFDEE;
  border: 1px solid #CCCCCC;
  border-radius: 8px;
  margin: 4px 8px;
  padding: 8px;
}
#${sid}_Settings-footer-buttons {
  text-align: right;
  button {
    border: 1px solid gray;
    border-radius: 4px;
    margin: 8px;
    padding: 4px;
    width: 8em;
  }
}
#${sid}_Settings-ok {
  background-color: #EEEEEE;
}
#${sid}_Settings-cancel {
  background-color: #EEEEEE;
}
.${sid}_Settings-tab-label {
  background: #BCBCBC;
  border-radius: 4px 4px 0 0;
  color: White;
  cursor: pointer;
  flex: 1;
  font-weight: bold;
  order: -1;
  padding: 2px 4px;
  position: relative;
  text-align: center;
  text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.2);
  white-space: nowrap;
  z-index: 1;
}
.${sid}_Settings-tab-label:not(:last-of-type) {
  margin-right: 5px;
}
.${sid}_Settings-tab-content {
  height: 0;
  opacity: 0;
  overflow: hidden;
  width: 100%;
}
.${sid}_Settings-tab-switch:checked + .${sid}_Settings-tab-label {
  background: #8899aa;
}
.${sid}_Settings-tab-switch:checked + .${sid}_Settings-tab-label + .${sid}_Settings-tab-content {
  box-shadow: 0 0 3px rgba(0, 0, 0, 0.2);
  height: auto;
  opacity: 1;
  overflow: auto;
  padding: 8px;
  transition: .5s opacity;
}
#${sid}_CommentReportForm-NgComment {
  color: #E6E6E6;
  font-size: 13px;
  margin-top: 12px;
}
#${sid}_CommentReportForm-NgCommentHeader {
  font-size: 12px;
}
#${sid}_CommentReportForm-NgCommentList {
  background-color: #333333;
  margin-top: 4px;
  padding: 8px 4px;
  p + p {
    margin-top: 0.8em;
  }
}
#${sid}_VideoResolution {
  background: linear-gradient(180deg, rgba(0, 0, 0, 0.2), #000000);
  bottom: 0px;
  color: #ccc;
  display: none;
  font-size: 12px;
  height: 16px;
  left: 155px;
  padding: 0 2px;
  position: absolute;
  user-select: none;
  white-space: nowrap;
}
#${sid}_ProgramInfo1,
#${sid}_ProgramInfo2 {
  backdrop-filter: blur(4px);
  border-radius: 8px;
  color: white;
  flex-direction: column;
  font-size: var(--${sid}-pi-font-size);
  left: 24px;
  max-height: calc(100cqh - 206px);
  max-width: calc(100cqw - 84px);
  overflow: hidden;
  padding: 0;
  position: fixed;
  scrollbar-width: thin;
  top: 68px;
  z-index: 3;
}
:is(#${sid}_ProgramInfo1, #${sid}_ProgramInfo2):not(.${sid}_ProgramInfo_hidden) {
  display: flex;
}
#${sid}_ProgramInfo1 {
  background: linear-gradient(180deg, rgba(0, 0, 0, 0.5), rgba(50, 50, 50, 0.5));
  box-shadow: 0 0 2px 2px rgba(0, 0, 0, 0.5);
  text-shadow:
    -1px -1px 1px black, -1px 0px 1px black, -1px 1px 1px black,
     0px -1px 1px black,  0px 1px 1px black,
     1px -1px 1px black,  1px 0px 1px black,  1px 1px 1px black;
}
#${sid}_ProgramInfo2 {
  line-height: calc(var(--${sid}-pi-font-size) + var(--${sid}-pi2-font-shadow) * 2);
  min-width: calc(100cqw - 316px);
  text-shadow:
    0 0 var(--${sid}-pi2-font-shadow) #222, 0 0 var(--${sid}-pi2-font-shadow) #222,
    0 0 var(--${sid}-pi2-font-shadow) #222, 0 0 var(--${sid}-pi2-font-shadow) #222,
    0 0 var(--${sid}-pi2-font-shadow) #222, 0 0 var(--${sid}-pi2-font-shadow) #222,
    0 0 var(--${sid}-pi2-font-shadow) #222, 0 0 var(--${sid}-pi2-font-shadow) #222,
    0 0 var(--${sid}-pi2-font-shadow) #222, 0 0 var(--${sid}-pi2-font-shadow) #222,
    0 0 var(--${sid}-pi2-font-shadow) #222, 0 0 var(--${sid}-pi2-font-shadow) #222,
    0 0 var(--${sid}-pi2-font-shadow) #222, 0 0 var(--${sid}-pi2-font-shadow) #222,
    0 0 var(--${sid}-pi2-font-shadow) #222, 0 0 var(--${sid}-pi2-font-shadow) #222;
  .${sid}_ProgramInfo-title {
    line-height: calc(var(--${sid}-pi-title-size) + var(--${sid}-pi2-title-shadow) * 2);
    padding: 0 var(--${sid}-pi2-title-shadow);
  }
}
:is(#${sid}_ProgramInfo1, #${sid}_ProgramInfo2) a {
  text-decoration: none;
  &:hover {
    text-decoration: underline;
  }
}
.${sid}_ProgramInfo-header {
  align-items: center;
  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
  display: flex;
  padding: 4px;
  position: sticky;
  text-shadow: none;
  top: 0;
  z-index: 10;
}
.${sid}_ProgramInfo-header-title {
  font-size: 0.9rem;
  margin-left: 8px;
  opacity: 0.8;
  pointer-events: none;
}
.${sid}_ProgramInfo-header-buttons {
  position: absolute;
  right: 8px;
  & > button {
    background: none;
    border: none;
    color: #ccc;
    cursor: pointer;
    font-size: 1.5rem;
    line-height: 1;
    padding: 0 4px;
    &:hover {
      color: white;
    }
  }
}
.${sid}_ProgramInfo-body {
  overflow-y: auto;
  padding: 2px 4px 8px;
  scrollbar-width: thin;
}
.${sid}_ProgramInfo-text {
  padding: 0 var(--${sid}-pi2-font-shadow);
}
.${sid}_ProgramInfo-title {
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  display: -webkit-box;
  font-size: var(--${sid}-pi-title-size);
  overflow: hidden;
  text-overflow: ellipsis;
}
.${sid}_ProgramInfo-labels {
  margin-right: 0.25em;
  > span {
    font-size: calc(1.5rem - 2px);
    margin-right: 4px;
    padding: 2px 3px;
    vertical-align: 1px;
  }
}
.${sid}_ProgramInfo-label-new,
.${sid}_ProgramInfo-label-live {
  background-color: red;
  color: white;
  text-shadow: 1px 1px black;
}
.${sid}_ProgramInfo-label-bundle,
.${sid}_ProgramInfo-label-binge,
.${sid}_ProgramInfo-label-pick {
  background-color: white;
  color: black;
  text-shadow: 0px 0px;
}
.${sid}_ProgramInfo-label-first,
.${sid}_ProgramInfo-label-last {
  background-color: transparent;
  border: 1px solid white;
  color: white;
  margin-right: 4px;
  text-shadow: 0px 0px;
}
.${sid}_ProgramInfo-label-last {
  margin-left: 4px;
  margin-right: 0;
}
.${sid}_ProgramInfo-startEndAt {
  margin-top: 0.5em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.${sid}_ProgramInfo-tsAt {
  display: flex;
  flex-wrap: wrap;
  margin: 0.5em 0 1.5em;
}
.${sid}_ProgramInfo-tsFreeEndAt,
.${sid}_ProgramInfo-tsEndAt {
  flex: 0 1 auto;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.${sid}_ProgramInfo-tsFreeEndAt {
  margin-right: 1em;
}
.${sid}_ProgramInfo-detailHighlight,
.${sid}_ProgramInfo-content {
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 6;
  display: -webkit-box;
  margin-top: 1em;
  overflow: hidden;
  text-overflow: ellipsis;
}
.${sid}_ProgramInfo-credit {
  display: grid;
  grid-template-columns: 1fr 1fr;
  margin-top: 1em;
}
.${sid}_ProgramInfo-credit-casts,
.${sid}_ProgramInfo-credit-crews {
  
}
.${sid}_ProgramInfo-credit2 {
  
}
.${sid}_ProgramInfo-credit2-casts {
  dl {
    display: grid;
    grid-auto-flow: dense;
    grid-column-gap: 1em;
    grid-template-columns: repeat(auto-fit, minmax(20em, 1fr));
  }
  dt {
    grid-column: 1 / -1;
  }
}
.${sid}_ProgramInfo-credit-copyrights {
  grid-column: span 2;
  margin-top: 1em;
}
.c-tv-NowOnAirContainer__screen:has(~ .c-tv-NowOnAirContainer__side-panel[aria-hidden="false"]) {
  container-name: tvScreen;
  container-type: inline-size;
}
@container tvScreen (max-width: 40em) {
  .${sid}_ProgramInfo-credit-casts,
  .${sid}_ProgramInfo-credit-crews {
    grid-column: span 2;
  }
  .${sid}_ProgramInfo-credit-casts {
    margin-bottom: 1em;
  }
}
    `,
      // 全般
      reduceCommentSpace = `
/*コメントの余白を減らす&行間を狭める*/
.com-tv-CommentBlock__inner,
.com-archive-comment-ArchiveCommentItem {
  padding: 2px 4px !important;
}
.com-comment-CommentItem__body {
  padding: 4px 0 !important;
}
.com-comment-CommentItem__body-sub-wrapper {
  padding: 0 4px !important;
}
.com-comment-CommentItem__sub {
  width: auto !important;
}
    `,
      mouseoverNavigation = `
/*左端にマウスオーバーしたときサイドナビゲーションを表示する*/
.com-application-SideNavigation--closed,
.com-application-SideNavigation__wrapper--closed {
  width: 16px !important;
}
.com-application-SideNavigation--closed:not(:hover) {
  opacity: 0 !important;
}
.com-tv-TVScreen__footer-container--sidenav-closed {
  padding-left: 0 !important;
}
.com-timetable-DesktopTimeTableWrapper__channel-content-header-wrapper--side-navigation-closed,
.com-timetable-TimeTableListTimeAxis--is-closed-side-navigation {
  left: 8px !important;
}
.c-application-DesktopAppContainer__content:has(.com-live-event-LiveEventPlayerSectionLayout__player-area) {
  left: 0;
  position: absolute;
}
      `,
      videoResolution = `
#${sid}_VideoResolution {
  display: block;
}
      `,
      headerPosition = `
/*ヘッダーを追従表示にする*/
body:not(.com-timetable-TimeTable__body-timetable) :is(
  .c-common-HeaderContainer-header,
  .com-application-Header:not(.com-application-Header--shrunk)
) {
  position: relative !important;
}
body:not(.com-timetable-TimeTable__body-timetable) .com-application-SideNavigation__wrapper {
  padding-top: 0 !important;
}
.com-a-ResponsiveMainContent {
  margin-top: 0 !important;
}
.com-application-SideNavigation__wrapper {
  height: calc(100vh - 68px) !important;
}
#main:has(.com-application-Header--scrolled) .com-application-SideNavigation__wrapper {
  height: 100vh !important;
  top: 0;
}
      `,
      semiTransparent = `
/*ヘッダーやサイドナビゲーションなどを半透明にする*/
.com-application-Header,
.com-application-Header:before {
  background: linear-gradient(0deg, rgba(0, 0, 0, 0.2), #000000) !important;
}
.com-InputText__input--dark-strong:not(:focus),
.com-live-event-LiveEventOverlayControllerLayout__bottom-buttons .com-a-Button--dark:not(:hover) {
  background: rgba(33, 33, 33, 0.2) !important;
}
.com-application-SideNavigation__wrapper {
  background-color: rgba(0, 0, 0, 0.2) !important;
  background-image: linear-gradient(270deg,transparent, #000) !important;
}
.com-tv-LinearFooter__button button {
  background-color: rgba(0, 0, 0, 0.2) !important;
}
.com-application-SideNavigation__wrapper:hover,
.com-tv-LinearFooter__button button:hover {
  background-color: rgba(0, 0, 0, 0.8) !important;
}
.com-tv-LinearChannelSwitcher__button {
  opacity: 0.5 !important;
}
.com-playback-Volume__icon {
  opacity: 0.8 !important;
}
.com-tv-LinearChannelSwitcher__button:hover,
.com-playback-Volume__icon:hover {
  opacity: 1 !important;
}
.com-tv-RemoteController__button:hover {
  border: 2px outset #555555 !important;
}
.com-a-Tooltip {
  background-color: rgba(33, 33, 33, 0.5) !important;
}
.com-question-QuestionContainerView .com-question-VoteContent {
  background-color: rgba(23, 23, 23, 0.6) !important;
}
.com-question-QuestionContainerView .com-question-VoteContent:hover {
  background-color: rgba(23, 23, 23, 1) !important;
}
.com-question-VoteButton:hover:not(.com-question-VoteButton--highest) {
  background-color: #171717 !important;
}
      `,
      smallFontSize = `
/*ヘッダーやフッターの一部の文字サイズを小さくする*/
.com-InputText__input {
  font-size: 14px !important;
}
.com-tv-LinearFooter__feed-super {
  font-size: 16px !important;
}
      `,
      overlapSidePanel = `
/*右側のサイドパネルを動画に重ねて表示する*/
.c-tv-NowOnAirContainer__tv-container,
.com-vod-VODResponsiveMainContent--wide-mode .c-tv-TimeshiftPlayerContainerView-screen,
.com-vod-VODResponsiveMainContent--with-side-panel {
  width: 100vw !important;
}
.c-tv-NowOnAirContainer__screen--with-side-panel {
  width: calc(100% - ${
    setting.sidePanelSize ? setting.sidePanelSizeNum : '320'
  }px) !important;
  .com-tv-TVScreen__footer-container {
    padding-right: ${
      setting.sidePanelSize ? setting.sidePanelSizeNum : '320'
    }px !important;
  }
}
.com-tv-TVScreen__player {
  height: 100vh !important;
}
.com-a-Text--info.com-a-Text--dark,
.com-a-Text--info.com-a-Text--light {
  color: #DDDDDD !important;
}
.com-tv-CommentBlock,
.com-tv-FeedProgramDetailContainerView__contents :is(h2, h3, h2+p, h3+p, dd),
.com-tv-FeedProgramDetailContainerView__contents span:not(.com-tv-FeedProgramDetailExternalLink__button-text),
.com-tv-FeedSidePanel__close-button-text,
.com-comment-CommentItem__body,
.com-archive-comment-ArchiveCommentHead__count > span,
.com-archive-comment-ArchiveCommentItem__message > span {
  text-shadow:
    -1px -1px 1px black, -1px 0px 1px black, -1px 1px 1px black,
     0px -1px 1px black,  0px 1px 1px black,
     1px -1px 1px black,  1px 0px 1px black,  1px 1px 1px black !important;
}
.com-tv-CommentBlock__message > span,
.com-comment-CommentItem__body,
.com-archive-comment-ArchiveCommentHead__count > span,
.com-archive-comment-ArchiveCommentItem__message > span {
  color: white !important;
}
.c-tv-NowOnAirContainer__side-panel,
.com-tv-CommentArea,
.com-tv-CommentArea__comment-form,
.com-tv-CommentBlock,
.com-tv-FeedProgramDetailContainerView__contents,
.com-tv-FeedSidePanel__header,
.com-comment-CommentContainerView,
.com-live-event-LiveEventStatsSidePanel,
.c-tv-TimeshiftPlayerContainerView__comment,
.com-archive-comment-ArchiveCommentItem,
.c-archive-comment-ArchiveCommentContainerView__no-input {
  background-color: rgba(0, 0, 0, 0) !important;
}
.com-tv-CommentBlock__inner:hover,
.com-comment-TwitterSigninButton:hover,
.com-comment-CommentItem__inner:hover,
.com-archive-comment-ArchiveCommentItem:hover {
  background-color: rgba(0, 0, 0, 0.3) !important;
}
.com-tv-CommentBlock__time {
  opacity: 0.6 !important;
}
.com-o-CommentForm__can-post .com-o-CommentForm__opened-textarea-wrapper,
.com-comment-CommentTextarea__textarea {
  background-color: rgba(255, 255, 255, 0.2) !important;
}
.com-o-CommentForm__can-post .com-o-CommentForm__opened-textarea:focus,
.com-comment-CommentTextarea__textarea:focus {
  background-color: rgba(255, 255, 255, 0.8) !important;
}
.com-o-CommentForm__opened-textarea::placeholder,
.com-comment-CommentTextarea__textarea::placeholder {
  color: #EEEEEE !important;
  opacity: 1 !important;
  text-shadow:
    -1px -1px 1px #666, -1px 0px 1px #666, -1px 1px 1px #666,
     0px -1px 1px #666,  0px 1px 1px #666,
     1px -1px 1px #666,  1px 0px 1px #666,  1px 1px 1px #666;
}
.com-o-CommentForm__opened-textarea:focus::placeholder,
.com-comment-CommentTextarea__textarea:focus::placeholder {
  color: #333333 !important;
  opacity: 1 !important;
  text-shadow: none !important;
}
.com-o-CommentForm__opened-textarea-wrapper {
  background-color: rgba(0, 0, 0, 0.2) !important;
}
.com-o-CommentForm__twitter-button,
.com-comment-TwitterSigninButton,
.com-comment-TwitterSignoutButton,
.com-comment-CommentAreaHeader__comment-count {
  opacity: 0.2 !important;
}
.com-o-CommentForm__twitter-button:hover,
.com-comment-TwitterSigninButton:hover,
.com-comment-TwitterSignoutButton:hover,
.com-comment-CommentAreaHeader__comment-count:hover {
  opacity: 1 !important;
}
.com-tv-FeedSidePanel__contents {
  height: 98% !important;
}
.com-a-Button--primary,
.com-comment-CommentSubmitButton {
  background-color: rgba(221, 170, 0, 0.5) !important;
}
.com-a-Button--primary:hover:not([disabled]),
.com-comment-CommentSubmitButton:hover:not([disabled]) {
  background-color: rgba(221, 170, 0, 1) !important;
}
.c-tv-NowOnAirContainer__screen--with-side-panel :is(
  .com-tv-SlotMyListButtonOnPlayerContainerView,
  .com-tv-TVScreen__ad-link-button
) {
  bottom: 124px !important;
  right: ${
    setting.sidePanelSize ? setting.sidePanelSizeNum : '320'
  }px !important;
  transform: translateX(500px) !important;
}
.com-tv-SlotMyListButtonOnPlayerContainerView--shown,
.com-tv-TVScreen__ad-link-button--shown {
  transform: translateX(0) !important;
}
.com-question-QuestionContainerView,
.com-vod-VODResponsiveMainContent--with-side-panel .com-live-event-LiveEventOverlayControllerLayout__bottom-buttons {
  right: ${
    setting.sidePanelSize ? setting.sidePanelSizeNum : '320'
  }px !important;
}
.com-vod-VODResponsiveMainContent--with-side-panel .com-vod-LiveEventPayperviewControlBar,
.com-vod-VODResponsiveMainContent--with-side-panel .com-vod-VideoControlBar__right {
  margin-right: ${
    setting.sidePanelSize ? setting.sidePanelSizeNum : '320'
  }px !important;
}
.com-tv-FeedProgramDetailCommentCounter,
.com-tv-FeedProgramDetailHeader__date,
.com-tv-FeedProgramDetailViewCounter {
  color: #CCCCCC !important;
}
.com-live-event-LiveEventPlayerSectionLayout__side-panel {
  height: calc(100vh - 10px);
  position: absolute;
  right: 0;
}
.com-live-event-LiveEventStatsSidePanel {
  background-color: rgba(0, 0, 0, 0.2) !important;
}
.com-comment-CommentAreaHeader__close-button:hover {
  color: #FFFFFF;
}
.com-o-CommentForm__opened-textarea:focus::placeholder {
  color: black;
}
.c-tv-TimeshiftPlayerContainerView__comment-wrapper {
  z-index: 20;
}
.c-tv-TimeshiftPlayerContainerView--has-comment .com-vod-VODScreen-video-control,
.c-tv-TimeshiftPlayerContainerView--has-comment .c-video_ad-VideoAdContainerView__info {
  margin: 0 !important;
  width: calc(100% - ${
    setting.sidePanelSize ? setting.sidePanelSizeNum : '320'
  }px);
  z-index: 30;
}
.c-tv-TimeshiftPlayerContainerView__comment:hover .com-archive-comment-ArchiveCommentHead__close {
  background-color: rgba(0, 0, 0, 0.3);
  color: white !important;
}
.c-archive-comment-ArchiveCommentContainerView__body-waiting-show {
  opacity: 0 !important;
  + .c-archive-comment-ArchiveCommentContainerView__list-wrapper {
    opacity: 0.5;
  }
}
    `,
      sidePanelBackground = `
/*サイドパネルの背景を半透明にする*/
.c-tv-NowOnAirContainer__side-panel,
.com-live-event-LiveEventPlayerSectionLayout__side-panel,
.c-tv-TimeshiftPlayerContainerView__comment {
  background-color: rgba(0, 0, 0, 0.5) !important;
}
      `,
      sidePanelSize = `
/*サイドパネルの幅を変更する*/
.c-tv-NowOnAirContainer__side-panel,
.com-tv-FeedSidePanel,
.com-live-event-LiveEventPlayerSectionLayout__side-panel,
.c-tv-TimeshiftPlayerContainerView--has-comment .c-tv-TimeshiftPlayerContainerView__comment-wrapper,
.c-tv-TimeshiftPlayerContainerView__comment {
  width: ${
    setting.sidePanelSize ? setting.sidePanelSizeNum : '320'
  }px !important;
}
.c-tv-NowOnAirContainer__screen--with-side-panel,
.com-application-Header--shrunk,
.c-common-HeaderContainer-header--shrunk {
  width: calc(100% - ${
    setting.sidePanelSize ? setting.sidePanelSizeNum : '320'
  }px) !important;
}
      `,
      hiddenIdAndPlan = `
/*サイドナビゲーションの視聴プランを隠す*/
.com-application-SideNavAccountItem__plan-label-container,
.com-application-SideNavAccountItem__plan-value {
  display: none !important;
}
.com-application-SideNavAccountItem__wrapper {
  --com-application-SideNavAccountItem__height: unset !important;
}
      `,
      notifyNewChannel = `
/*チャンネルが追加されたら通知する*/
.${sid}_NotifyNewChannel {
  align-items: flex-start;
  background-color: rgba(255, 255, 255, 0.9);
  border-radius: 4px;
  top: 0px;
  right: 0px;
  box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.5);
  color: black;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  font-size: 14px;
  margin: 16px;
  max-height: calc(100vh - 32px);
  overflow-y: auto;
  padding: 12px;
  position: fixed;
  scrollbar-width: thin;
  transition: transform 0.3s ease-out;
  transform: translateY(-150%);
  z-index: 10000;
}
.${sid}_NotifyNewChannel--shown {
  transform: translateY(0);
}
.${sid}_NotifyNewChannel__header {
  align-items: center;
  display: flex;
  gap: 8px;
  width: 100%;
}
.${sid}_NotifyNewChannel__text {
  font-weight: bold;
}
.${sid}_NotifyNewChannel__images {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.${sid}_NotifyNewChannel__item {
  align-items: center;
  display: flex;
  gap: 8px;
  margin-top: 8px;
  width: 100%;
}
.${sid}_NotifyNewChannel__program-title {
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  display: -webkit-box;
  font-weight: bold;
  line-height: 1.3;
  max-width: 30em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: normal;
}
.${sid}_NotifyNewChannel__program-time {
  font-size: 12px;
  margin-left: auto;
  padding-left: 8px;
  white-space: nowrap;
}
.${sid}_NotifyNewChannel__img {
  background-color: black;
  border-radius: 2px;
  flex-shrink: 0;
  height: 56px;
  object-fit: contain;
  padding: 4px;
  width: auto;
}
      `,
      // テレビ
      sidePanelCloseButton = `
/*サイドパネル上端にマウスカーソルを近づけたとき閉じるボタンを表示*/
.com-tv-FeedSidePanel__close-button {
  background-color: rgba(64,64,64,0.8) !important;
}
.com-tv-FeedSidePanel__contents {
  transform: translateY(10px) !important;
}
.com-tv-FeedSidePanel__header {
  position: absolute !important;
  transform: translateY(-58px) !important;
  transition: transform 0.2s !important;
  z-index: 99 !important;
  &:hover {
    transform: translateY(0px) !important;
  }
}
    `,
      showProgramDetail = `
/*サイドパネルに記載された番組情報の詳細を常に表示する*/
.com-tv-FeedSidePanel__contents #com-vod-VODDetailsAccordion__details {
  height: auto !important;
}
.com-tv-FeedSidePanel__contents .com-vod-VODDetailsAccordion__details--collapsed {
  visibility: visible !important;
}
.com-tv-FeedSidePanel__contents .com-vod-VODDetailsAccordion__toggle-collapsed-details-button-paragraph {
  display: none !important;
}
    `,
      hiddenButtonText = `
/*最初から見る・番組情報・コメントボタンのテキストを隠す*/
.com-tv-LinearFooterChasePlayButton,
.com-tv-LinearFooterProgramDetailButton,
.com-tv-LinearFooterCommentButton {
  transition: all 0.2s !important;
  width: 44px !important;
}
.com-tv-LinearFooterChasePlayButton:hover:not(.com-tv-LinearFooterChasePlayButton--shrunk) {
  width: 132px !important;
}
.com-tv-LinearFooterProgramDetailButton:hover:not(.com-tv-LinearFooterProgramDetailButton--shrunk),
.com-tv-LinearFooterCommentButton:hover:not(.com-tv-LinearFooterCommentButton--shrunk) {
  width: 100px !important;
}
.com-tv-LinearFooterChasePlayButton__text,
.com-tv-LinearFooterProgramDetailButton__text,
.com-tv-LinearFooterCommentButton__text {
  overflow: hidden;
  white-space: nowrap;
  width: 0;
}
.com-tv-LinearFooterChasePlayButton:hover .com-tv-LinearFooterChasePlayButton__text,
.com-tv-LinearFooterProgramDetailButton:hover .com-tv-LinearFooterProgramDetailButton__text,
.com-tv-LinearFooterCommentButton:hover .com-tv-LinearFooterCommentButton__text {
  width: auto;
}
    `,
      nextPrograms = `
/*チャンネルリストに次以降の番組を表示する*/
.com-tv-LinearChannelListItem__inner:has(.${sid}_NextProgramItem__details) {
  align-items: center !important;
  background: linear-gradient(to right, rgba(0, 0, 0, 0.3) 0px 220px, rgba(22, 72, 90, 0.3));
  display: flex !important;
  justify-content: flex-start !important;
  max-width: calc(100vw - 80px);
  overflow: hidden;
}
.com-tv-LinearChannelListItem__inner:has(.${sid}_NextProgramItem__details):hover {
  background: linear-gradient(to right, rgba(0, 0, 0, 0.7) 0px 220px, rgba(22, 72, 90, 0.7));
}
.c-tv-NowOnAirContainer__screen--with-side-panel .com-tv-LinearChannelListItem__inner:has(.${sid}_NextProgramItem__details) {
  max-width: calc(100vw - 80px - ${
    setting.sidePanelSize ? setting.sidePanelSizeNum : '320'
  }px);
}
.com-tv-LinearChannelListItem__details:has(+ .${sid}_NextProgramItem__details),
.${sid}_NextProgramItem__details {
  flex-shrink: 0 !important;
}
.${sid}_NextProgramItem__details {
  flex-basis: auto;
  flex-grow: 0;
  margin-left: 8px;
  padding-left: 8px;
  width: 110px;
}
.${sid}_NextProgramItem__title {
  color: #f6f6f6;
  font-size: 12px;
  font-weight: bold;
  line-height: 1.3;
}
.${sid}_NextProgramItem__title-text {
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  display: -webkit-box;
  line-height: 1.3;
  max-height: 2.6em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: normal;
}
.${sid}_NextProgramItem__title-text:before {
  content: "";
  position: static;
}
.${sid}_NextProgramItem__title-text:after {
  content: "";
  float: none;
} 
.${sid}_NextProgramItem__broadcasting-date {
  color: #f0f0f0;
  font-size: 12px;
  line-height: 1.3;
  margin-top: 2px;
}
      `,
      searchProgram = `
/*番組の検索結果をページ遷移せずに表示する*/
#${sid}_SearchResults {
  background: rgba(20, 20, 20, 0.75);
  backdrop-filter: blur(4px);
  border: 1px solid rgba(255, 255, 255, 0.2);
  border-radius: 12px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
  color: white;
  display: flex;
  flex-direction: column;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  margin: auto;
  height: fit-content;
  max-height: 80vh;
  width: fit-content;
  min-width: 40em;
  padding: 0;
  position: fixed;
  z-index: 9500;
  transition: all 0.2s ease-in-out;
}
.c-tv-NowOnAirContainer__screen--with-side-panel #${sid}_SearchResults {
  right: ${setting.sidePanelSize ? setting.sidePanelSizeNum : '320'}px;
}
.c-tv-NowOnAirContainer__screen--with-side-panel #${sid}_SearchResults.is-showing-detail {
  max-width: calc(100vw - 48px - ${
    setting.sidePanelSize ? setting.sidePanelSizeNum : '320'
  }px);
}
#${sid}_SearchResults.is-showing-detail {
  top: 68px;
  bottom: 24px;
  left: 24px;
  right: auto;
  margin: 0;
  width: fit-content;
  max-width: calc(100vw - 48px);
  max-height: calc(100vh - 92px);
}
.${sid}_SearchResults-hidden {
  display: none !important;
}
.${sid}_SearchResults-header {
  align-items: center;
  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
  display: flex;
  justify-content: space-between;
  padding: 12px 16px;
}
.${sid}_SearchResults-title {
  flex: 1;
  font-size: 1.1rem;
  font-weight: bold;
  margin-right: 8px;
  overflow: hidden;
  padding-top: 1px;
  text-overflow: ellipsis;
  white-space: nowrap;
  a {
    color: inherit;
    text-decoration: none;
    &:hover {
      text-decoration: underline;
    }
  }
}
.${sid}_SearchResults-header-buttons {
  flex-shrink: 0;
  white-space: nowrap;
  & > button {
    background: none;
    border: none;
    color: #ccc;
    cursor: pointer;
    line-height: 1;
    padding: 0 4px;
    &:hover {
      color: white;
    }
  }
}
#${sid}_SearchResults-back,
#${sid}_SearchResults-detail-back {
  margin-right: 8px;
  vertical-align: 2px;
}
#${sid}_SearchResults-detail-back {
  display: none;
}
#${sid}_SearchResults.is-showing-detail #${sid}_SearchResults-detail-back {
  display: inline-block;
}
#${sid}_SearchResults.is-showing-detail #${sid}_SearchResults-back,
#${sid}_SearchResults.is-showing-help :is(#${sid}_SearchResults-back, #${sid}_SearchResults-detail-back) {
  display: none;
}
#${sid}_SearchResults-close {
  font-size: 1.5rem;
  vertical-align: -2px;
}
#${sid}_SearchResults-help-btn {
  font-size: 1.2rem;
  margin-right: 8px;
  &[title="検索結果に戻る"] {
    font-size: inherit;
    vertical-align: 2px;
  }
}
.${sid}_SearchResults-help-content {
  display: none;
  line-height: 1.6;
  max-height: 100%;
  overflow-y: auto;
  padding: 16px;
  scrollbar-width: thin;
  h4 {
    border-bottom: 1px solid #555;
    margin-bottom: 8px;
    padding-bottom: 4px;
    position: sticky;
    top: 0;
    background: inherit;
  }
  ul {
    list-style: disc;
    margin-left: 20px;
  }
  li {
    margin-bottom: 6px;
  }
  p {
    margin-bottom: 8px;
  }
  p:has(b) {
    margin-top: 2em;
    margin-bottom: 1em;
  }
  b {
    font-size: 1rem;
  }
  code {
    background: rgba(255, 255, 255, 0.1);
    border-radius: 3px;
    font-family: monospace;
    padding: 2px 4px;
  }
}
.${sid}_SearchResults-help-content.is-visible {
  display: block;
}
.${sid}_SearchResults-list.is-hidden {
  display: none;
}
.${sid}_SearchResults-list {
  overflow-y: auto;
  padding: 8px 16px;
  scrollbar-width: thin;
}
.${sid}_SearchResults-noitem {
  color: #aaa;
  padding: 24px;
  text-align: center;
}
.${sid}_SearchResults-item {
  border-bottom: 1px solid rgba(255, 255, 255, 0.05);
  padding: 8px 0;
  &:last-child {
    border-bottom: none;
  }
  &.is-past {
    opacity: 0.8;
  }
  position: relative;
  padding-left: 12px;
}
.${sid}_SearchResults-item-sidebar {
  position: absolute;
  left: 0;
  top: 12px;
  bottom: 12px;
  width: 3px;
  border-radius: 2px;
}
/* カラーパターン: 過去(橙系) | 今日(白) | 未来(青系) */
.is-pre-7 .${sid}_SearchResults-item-sidebar { background: #885533; }
.is-pre-6 .${sid}_SearchResults-item-sidebar { background: #976442; }
.is-pre-5 .${sid}_SearchResults-item-sidebar { background: #a57250; }
.is-pre-4 .${sid}_SearchResults-item-sidebar { background: #b3805e; }
.is-pre-3 .${sid}_SearchResults-item-sidebar { background: #c18e6c; }
.is-pre-2 .${sid}_SearchResults-item-sidebar { background: #cf9c7a; }
.is-pre-1 .${sid}_SearchResults-item-sidebar { background: #ddaa88; }
.is-today .${sid}_SearchResults-item-sidebar { background: #dddddd; box-shadow: 0 0 3px #ddddddcc; }
.is-next-1 .${sid}_SearchResults-item-sidebar { background: #88aacc; }
.is-next-2 .${sid}_SearchResults-item-sidebar { background: #7d9fbe; }
.is-next-3 .${sid}_SearchResults-item-sidebar { background: #7294b0; }
.is-next-4 .${sid}_SearchResults-item-sidebar { background: #6789a2; }
.is-next-5 .${sid}_SearchResults-item-sidebar { background: #5c7e94; }
.is-next-6 .${sid}_SearchResults-item-sidebar { background: #517386; }
.is-next-7 .${sid}_SearchResults-item-sidebar { background: #446677; }
.${sid}_SearchResults-item-header {
  align-items: center;
  display: flex;
  font-size: 0.85rem;
  gap: 12px;
  margin-bottom: 4px;
}
.${sid}_SearchResults-item-channel {
  color: #aaa;
  a {
    text-decoration: none;
    &:hover {
      color: #ccc;
      text-decoration: underline;
    }
  }
}
.${sid}_SearchResults-item-time {
  color: #aaa;
}
.${sid}_SearchResults-item-title {
  font-size: 1rem;
  margin-bottom: 4px;
  a {
    color: #fff;
    text-decoration: none;
    &:hover {
      text-decoration: underline;
    }
  }
  span[class^="${sid}_ProgramInfo-label-"] {
    font-size: calc(1rem - 2px);
    margin-right: 4px;
    padding: 1px 2px;
    vertical-align: 1px;
  }
  span.${sid}_ProgramInfo-label-last {
    margin-left: 4px;
    margin-right: 0;
  }
}
.${sid}_SearchResults-item-highlight {
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  color: #bbb;
  display: -webkit-box;
  font-size: 0.9rem;
  overflow: hidden;
}
.${sid}_SearchResults-item-ts-free,
.${sid}_SearchResults-item-ts-prem {
  border-radius: 2px;
  cursor: help;
  font-size: 0.8em;
  margin-left: 8px;
  padding: 1px 4px;
  vertical-align: 1px;
}
.${sid}_SearchResults-item-ts-free {
  background: #00AA00;
  color: white;
}
.${sid}_SearchResults-item-ts-prem {
  background: #AA8800;
  color: white;
}
#${sid}_SearchResults-list.is-hidden {
  display: none;
}
.${sid}_SearchResults-detail {
  display: none;
  overflow-y: auto;
  padding: 16px;
  scrollbar-width: thin;
  position: relative;
}
.${sid}_SearchResults-detail.is-visible {
  display: block;
}
.${sid}_SearchResults-detail-close {
  position: sticky;
  top: 0;
  float: right;
  background: none;
  border: none;
  color: #ccc;
  cursor: pointer;
  font-size: 1rem;
  line-height: 1;
  padding: 0;
  z-index: 10;
  &:hover {
    color: white;
  }
}
.${sid}_SearchResults-detail-content {
  margin-top: -0.5rem;
}
      `,
      // ビデオ・見逃し視聴・ライブイベント
      videoPadding = `
/*動画周辺の余白を減らす*/
html:has(.com-vod-VODResponsiveMainContent--wide-mode, .com-vod-VODResponsiveMainContent--with-side-panel) {
  scrollbar-width: none;
}
.com-vod-VODResponsiveMainContent {
  margin: 0 !important;
  padding: 0 !important;
  .com-m-BreadcrumbList {
    padding: 7px 0;
  }
}
.com-vod-VODResponsiveMainContent__inner {
  max-width: none !important;
}
.c-application-DesktopAppContainer__content-container:has(.com-vod-VODResponsiveMainContent--wide-mode, .com-vod-VODResponsiveMainContent--with-side-panel) > :is(
  .com-application-SideNavigation--closed,
  .com-application-SideNavigation__wrapper--closed
) {
  width: 0 !important;
}
      `,
      // コメント
      highlightNewComment = `
/*新規コメントと自分のコメントを強調表示する*/
.com-tv-CommentBlock__inner--active,
.com-archive-comment-ArchiveCommentItem__is-active,
.com-tv-CommentBlock--new,
.com-comment-CommentItem[data-${sid.toLowerCase()}-Own] .com-comment-CommentItem__body,
.com-comment-CommentItem__inner[data-${sid.toLowerCase()}-Own] .com-comment-CommentItem__body {
  text-shadow:
    -1px -1px 1px black, -1px 0px 1px black, -1px 1px 1px black,
     0px -1px 1px black,  0px 1px 1px black,
     1px -1px 1px black,  1px 0px 1px black,  1px 1px 1px black,
    -2px -2px 1px rgba(255,165,0,0.3), -2px 0px 1px rgba(255,165,0,0.3), -2px 2px 1px rgba(255,165,0,0.3),
     0px -2px 1px rgba(255,165,0,0.3),  0px 2px 1px rgba(255,165,0,0.3),
     2px -2px 1px rgba(255,165,0,0.3),  2px 0px 1px rgba(255,165,0,0.3),  2px 2px 1px rgba(255,165,0,0.3) !important;
}
.com-tv-CommentBlock__inner--active,
.com-archive-comment-ArchiveCommentItem__is-active,
.com-comment-CommentItem[data-${sid.toLowerCase()}-Own],
.com-comment-CommentItem__inner[data-${sid.toLowerCase()}-Own] {
  background-color: rgba(128,83,0,0.3) !important;
}
    `,
      highlightFirstComment = `
/*初回コメントと連投コメントを強調表示する*/
.com-tv-CommentBlock__inner[data-${sid.toLowerCase()}-Green],
.com-comment-CommentItem__inner[data-${sid.toLowerCase()}-Green] .com-comment-CommentItem__body,
.com-archive-comment-ArchiveCommentItem__message[data-${sid.toLowerCase()}-Green] span {
  text-shadow:
      -1px -1px 1px black, -1px 0px 1px black, -1px 1px 1px black,
      0px -1px 1px black,  0px 1px 1px black,
      1px -1px 1px black,  1px 0px 1px black,  1px 1px 1px black,
      -2px -2px 1px rgba(0,192,0,0.6), -2px 0px 1px rgba(0,192,0,0.6), -2px 2px 1px rgba(0,192,0,0.6),
      0px -2px 1px rgba(0,192,0,0.6),  0px 2px 1px rgba(0,192,0,0.6),
      2px -2px 1px rgba(0,192,0,0.6),  2px 0px 1px rgba(0,192,0,0.6),  2px 2px 1px rgba(0,192,0,0.6) !important;
}
.com-tv-CommentBlock__inner[data-${sid.toLowerCase()}-Purple],
.com-comment-CommentItem__inner[data-${sid.toLowerCase()}-Purple] .com-comment-CommentItem__body,
.com-archive-comment-ArchiveCommentItem__message[data-${sid.toLowerCase()}-Purple] span {
  text-shadow:
    -1px -1px 1px black, -1px 0px 1px black, -1px 1px 1px black,
     0px -1px 1px black,  0px 1px 1px black,
     1px -1px 1px black,  1px 0px 1px black,  1px 1px 1px black,
    -2px -2px 1px rgba(192,96,192,0.6), -2px 0px 1px rgba(192,96,192,0.6), -2px 2px 1px rgba(192,96,192,0.6),
     0px -2px 1px rgba(192,96,192,0.6),  0px 2px 1px rgba(192,96,192,0.6),
     2px -2px 1px rgba(192,96,192,0.6),  2px 0px 1px rgba(192,96,192,0.6),  2px 2px 1px rgba(192,96,192,0.6) !important;
}
.com-tv-CommentBlock__inner[data-${sid.toLowerCase()}-Red],
.com-comment-CommentItem__inner[data-${sid.toLowerCase()}-Red] .com-comment-CommentItem__body,
.com-archive-comment-ArchiveCommentItem__message[data-${sid.toLowerCase()}-Red] span {
  text-shadow:
    -1px -1px 1px black, -1px 0px 1px black, -1px 1px 1px black,
     0px -1px 1px black,  0px 1px 1px black,
     1px -1px 1px black,  1px 0px 1px black,  1px 1px 1px black,
    -2px -2px 1px rgba(255,0,0,0.8), -2px 0px 1px rgba(255,0,0,0.8), -2px 2px 1px rgba(255,0,0,0.8),
     0px -2px 1px rgba(255,0,0,0.8),  0px 2px 1px rgba(255,0,0,0.8),
     2px -2px 1px rgba(255,0,0,0.8),  2px 0px 1px rgba(255,0,0,0.8),  2px 2px 1px rgba(255,0,0,0.8) !important;
}
.com-tv-CommentBlock__inner[data-${sid.toLowerCase()}-Yellow],
.com-comment-CommentItem__inner[data-${sid.toLowerCase()}-Yellow] .com-comment-CommentItem__body,
.com-archive-comment-ArchiveCommentItem__message[data-${sid.toLowerCase()}-Yellow] span {
  text-shadow:
    -1px -1px 1px black, -1px 0px 1px black, -1px 1px 1px black,
     0px -1px 1px black,  0px 1px 1px black,
     1px -1px 1px black,  1px 0px 1px black,  1px 1px 1px black,
    -2px -2px 1px rgba(224,224,0,0.8), -2px 0px 1px rgba(224,224,0,0.8), -2px 2px 1px rgba(224,224,0,0.8),
     0px -2px 1px rgba(224,224,0,0.8),  0px 2px 1px rgba(224,224,0,0.8),
     2px -2px 1px rgba(224,224,0,0.8),  2px 0px 1px rgba(224,224,0,0.8),  2px 2px 1px rgba(224,224,0,0.8) !important;
}
    `,
      commentFontSize = `
/*コメントの文字サイズを変更する*/
.com-tv-CommentBlock__message > span,
.com-comment-CommentItem__body,
.com-archive-comment-ArchiveCommentItem__message > span {
  font-size: ${setting.commentFontSizeNum}px !important;
}
      `,
      hiddenCommentList = `
/*コメントリストを半透明化*/
.com-a-OnReachTop, .com-comment-CommentList__inner,
.com-o-CommentForm__opened-textarea::placeholder,
.com-comment-CommentTextarea__textarea::placeholder {
  opacity: ${Number(setting.hiddenCommentListNum) / 100} !important;
}
    `,
      hiddenCommentList2 = `
/*コメントリストを半透明化2*/
.com-a-OnReachTop, .com-comment-CommentList__inner,
.com-o-CommentForm__opened-textarea::placeholder,
.com-comment-CommentTextarea__textarea::placeholder {
  opacity: ${Number(setting.hiddenCommentListNum2) / 100} !important;
}
    `,
      style = document.createElement('style');
    if (s === 'init') {
      style.textContent = init;
      // 全般
    } else if (s === 'reduceCommentSpace') {
      style.textContent = reduceCommentSpace;
    } else if (s === 'mouseoverNavigation') {
      style.textContent = mouseoverNavigation;
    } else if (s === 'videoResolution') {
      style.textContent = videoResolution;
    } else if (s === 'headerPosition') {
      style.textContent = headerPosition;
    } else if (s === 'semiTransparent') {
      style.textContent = semiTransparent;
    } else if (s === 'smallFontSize') {
      style.textContent = smallFontSize;
    } else if (s === 'overlapSidePanel') {
      style.textContent = overlapSidePanel;
    } else if (s === 'sidePanelBackground') {
      style.textContent = sidePanelBackground;
    } else if (s === 'sidePanelSize') {
      style.textContent = sidePanelSize;
    } else if (s === 'hiddenIdAndPlan') {
      style.textContent = hiddenIdAndPlan;
    } else if (s === 'notifyNewChannel') {
      style.textContent = notifyNewChannel;
      // テレビ
    } else if (s === 'sidePanelCloseButton') {
      style.textContent = sidePanelCloseButton;
    } else if (s === 'showProgramDetail') {
      style.textContent = showProgramDetail;
    } else if (s === 'hiddenButtonText') {
      style.textContent = hiddenButtonText;
    } else if (s === 'nextPrograms') {
      style.textContent = nextPrograms;
    } else if (s === 'searchProgram') {
      style.textContent = searchProgram;
      // ビデオ・見逃し視聴・ライブイベント
    } else if (s === 'videoPadding') {
      style.textContent = videoPadding;
      // コメント
    } else if (s === 'highlightNewComment') {
      style.textContent = highlightNewComment;
    } else if (s === 'highlightFirstComment') {
      style.textContent = highlightFirstComment;
    } else if (s === 'commentFontSize') {
      style.textContent = commentFontSize;
    } else if (s === 'hiddenCommentList') {
      style.textContent = hiddenCommentList;
    } else if (s === 'hiddenCommentList2') {
      style.textContent = hiddenCommentList2;
    }
    style.id = `${sid}_style_${s}`;
    document.head.appendChild(style);
  };

  /**
   * 可能であれば動画の上側や左側に隙間がなくなるようにページをスクロールする
   */
  const adjustScrollPosition = () => {
    log('adjustScrollPosition');
    const player = document.querySelector(selector.videoMainPlayer);
    if (player) {
      player.scrollIntoView({ behavior: 'smooth', inline: 'start' });
    }
  };

  /**
   * 動画を構成している要素に変更があったとき
   */
  const changeElements = () => {
    const content = returnContentType();
    if (content === 'tv') {
      if (setting.closeSidePanel) checkSidePanel();
      const feed = document.querySelector(selector.footerFeed);
      if (feed) {
        const text = feed.textContent;
        if (text && data.footerFeed !== text) {
          data.footerFeed = text;
          checkSwitchedProgram();
        }
      }
      // サイドパネルのリストが表示されている場合のみ次の番組情報を更新
      if (
        setting.nextPrograms &&
        document.querySelector(selector.tvChannelListItem)
      ) {
        showNextPrograms();
      }
    } else if (/ts|vi/.test(content)) {
      closeNextProgramInfo();
    }
    hasCommentElement();
    const inner = document.querySelector(selector.inner),
      reports = document.querySelectorAll(selector.commentReport2);
    if (inner) {
      setTimeout(() => {
        hasVideoElement();
        checkVisibleFooter();
      }, 250);
    }
    if (setting.reportFormCommentList) {
      const report = document.querySelector(selector.commentReport);
      if (report instanceof HTMLFormElement) {
        report.dataset[`${sid.toLowerCase()}Commentreportform`] = String(
          Date.now(),
        );
        let uid;
        if (content === 'tv') {
          uid = getCommentProps(report, 'userId');
        } else if (content === 'ts') {
          const p = report.parentElement
            ? report.parentElement.querySelector('p')
            : null;
          if (p instanceof HTMLParagraphElement) {
            const cid = getCommentProps(report, 'commentId', content);
            uid = p.dataset[`${sid.toLowerCase()}UserId`];
            if (!uid && data.newComments && setArchiveComments(0)) {
              /** @type {NodeListOf<HTMLDivElement>|null} */
              const noIdComment = document.querySelectorAll(selector.commentTs),
                noIdCommentInner = document.querySelectorAll(
                  selector.commentInnerTs,
                );
              let reset = false;
              if (noIdCommentInner.length) data.newComments = false;
              for (let i = 0, j = noIdCommentInner.length; i < j; i++) {
                const eMessage = noIdCommentInner[i];
                if (
                  eMessage instanceof HTMLParagraphElement &&
                  eMessage.parentElement instanceof HTMLDivElement
                ) {
                  const cid2 = getCommentProps(
                    eMessage.parentElement,
                    'commentId',
                    content,
                  );
                  if (cid && !uid) {
                    const comment = data.archiveComments.find(
                      (c) => c.id === cid,
                    );
                    if (comment) uid = comment.userId;
                  }
                  if (cid2) {
                    const comment2 = data.archiveComments.find(
                      (c) => c.id === cid2,
                    );
                    if (comment2) {
                      reset = true;
                      eMessage.dataset[`${sid.toLowerCase()}UserId`] =
                        comment2.userId;
                    }
                  }
                }
              }
              if (reset) {
                const listP = document.querySelector(
                  selector.commentList,
                )?.parentElement;
                if (noIdComment.length && listP) {
                  ngComment(noIdComment, listP, content, true);
                }
              }
            }
          }
        } else if (content === 'le') {
          const eProps = report.parentElement?.parentElement;
          if (eProps instanceof HTMLLIElement) {
            uid = getCommentProps(eProps, 'userId');
          }
        }
        if (uid) reportformUserComment(report, content, uid);
      }
    }
    checkSearchInput();
    if (reports.length > 1) {
      let minTime = Infinity,
        oldForm = null;
      for (let i = 0, j = reports.length; i < j; i++) {
        const form = reports[i];
        if (form instanceof HTMLFormElement) {
          const timeS = form.dataset[`${sid.toLowerCase()}Commentreportform`];
          if (timeS) {
            const time = parseInt(timeS);
            if (time < minTime) {
              minTime = time;
              oldForm = reports[i];
            }
          }
        }
      }
      if (oldForm) {
        const cancel = oldForm.querySelector(selector.commentReportCancel);
        if (cancel instanceof HTMLButtonElement) {
          cancel.click();
        }
      }
    }
  };

  /**
   * 指定したイベントリスナーを登録/解除する
   * @param {boolean} b true:登録, false:解除
   * @param {HTMLElement|null|undefined} e 登録/解除する要素
   * @param {string} s
   */
  const changeEventListener = (b, e, s) => {
    if (e) {
      if (s === 'commentScroll') {
        if (b) {
          e.removeEventListener('mouseenter', checkMouseEnter);
          e.removeEventListener('mouseleave', checkMouseLeave);
          e.addEventListener('mouseenter', checkMouseEnter);
          e.addEventListener('mouseleave', checkMouseLeave);
        } else {
          e.removeEventListener('mouseenter', checkMouseEnter);
          e.removeEventListener('mouseleave', checkMouseLeave);
        }
      }
    } else {
      log('changeEventListener: not found element', s);
    }
  };

  /**
   * ページタイトルもしくはURLが変更したとき
   */
  const changePageTitle = () => {
    const content = returnContentType(),
      title = document.title,
      url = location.href;
    if (data.href !== url || data.title !== title) {
      data.href = url;
      data.title = title;
      if (/tv|tt/.test(content)) {
        removeStyle('headerPosition');
      } else reStyle('headerPosition', setting.headerPosition);
      if (content === 'tt') {
        removeStyle('mouseoverNavigation');
        if (setting.reduceNavigation) hasSideNavigation();
      } else reStyle('mouseoverNavigation', setting.mouseoverNavigation);
      if (content === 'tv') {
        clearInterval(interval.changePageTitle);
        interval.changePageTitle = setTimeout(checkSwitchedProgram, 1000);
      }
    }
  };

  /**
   * 動画の画質を変更する
   * @param {Number} n 0:自動 1:180p 2:240p 3:360p 4:480p 5:720p 6:1080p
   */
  const changeTargetQuality = (n) => {
    if (!setting.qualityEnable) return;
    const vt = returnVideoTracks();
    const vc = document.querySelector(selector.videoContainer);
    if (vt?.qualities?.length) {
      const vi = returnVideo();
      const qualities = vt.qualities;
      const heightList = [0, 180, 240, 360, 480, 720, 1080];
      const targetHeight = heightList[n];
      let target = -1;

      if (qualities.length === 1) {
        target = 0;
      } else {
        const firstHeight = qualities[0]?.height || 0;
        const lastHeight = qualities.at(-1)?.height || 0;
        if (firstHeight < lastHeight) {
          target = qualities.findLastIndex((q) => q.height <= targetHeight);
          if (target === -1) target = 0;
        } else {
          target = qualities.findIndex((q) => q.height <= targetHeight);
          if (target === -1) target = qualities.length - 1;
        }
      }

      log(
        'changeTargetQuality',
        n,
        qualities.length,
        target,
        targetHeight,
        qualities[target]?.height,
        vi?.videoHeight,
      );

      clearInterval(interval.changeTargetQuality);
      interval.changeTargetQuality = setInterval(() => {
        const currentVt = returnVideoTracks();
        if (currentVt) {
          if (target >= 0 && qualities[target]) {
            if (currentVt.targetQuality?.height !== qualities[target].height) {
              log('changeTargetQuality Set', qualities[target].height);
              currentVt.targetQuality = qualities[target];
            }
          } else if (currentVt.targetQuality) {
            log('changeTargetQuality Clear');
            currentVt.targetQuality = null;
          }
        } else {
          clearInterval(interval.changeTargetQuality);
        }
      }, 2000);
    } else if (vc instanceof HTMLDivElement) {
      const content = returnContentType();
      const as = getCommentProps(vc, 'AdaptationSet', content);
      const abr = getCommentProps(vc, 'abr', content);
      const heightList = [0, 180, 240, 360, 480, 720, 1080];

      if (Array.isArray(as) && abr) {
        for (const e of as) {
          let target = -1;
          if (
            e?.mimeType &&
            /^video\//.test(e.mimeType) &&
            Array.isArray(e.Representation)
          ) {
            const vHeight = e.Representation.map((r) => r.height);
            const vBandwidth = e.Representation.map((r) =>
              Math.ceil((r.bandwidth || 0) / 1000),
            );
            if (n > 0) {
              vHeight.sort((a, b) => a - b);
              vBandwidth.sort((a, b) => a - b);
              const index = vHeight.findIndex((v) => v >= heightList[n]);
              target = index !== -1 ? vBandwidth[index] : vBandwidth.at(-1);
            }
            if (abr.initialBitrate && abr.maxBitrate && abr.minBitrate) {
              abr.initialBitrate.video = target >= 0 ? target : -1;
              abr.maxBitrate.video = target >= 0 ? target : -1;
              abr.minBitrate.video = target >= 0 ? target : -1;
              log('changeTargetQuality video', n, target, vHeight, vBandwidth);
            }
          } else if (
            e?.mimeType &&
            /^audio\//.test(e.mimeType) &&
            Array.isArray(e.Representation)
          ) {
            const aHeight = e.Representation.map((r) => parseInt(r.id, 10));
            const aBandwidth = e.Representation.map((r) =>
              Math.ceil((r.bandwidth || 0) / 1000),
            );
            if (n > 0) {
              aHeight.sort((a, b) => a - b);
              aBandwidth.sort((a, b) => a - b);
              const index = aHeight.findIndex((v) => v >= heightList[n]);
              target = index !== -1 ? aBandwidth[index] : aBandwidth.at(-1);
            }
            if (abr.initialBitrate && abr.maxBitrate && abr.minBitrate) {
              abr.initialBitrate.audio = target >= 0 ? target : -1;
              abr.maxBitrate.audio = target >= 0 ? target : -1;
              abr.minBitrate.audio = target >= 0 ? target : -1;
              log('changeTargetQuality audio', n, target, aHeight, aBandwidth);
            }
          }
        }
      }
    }
  };

  /**
   * 動画のソースが切り替わったとき
   */
  const changeVideoSource = () => {
    clearInterval(interval.videosource);
    interval.videosource = setInterval(() => {
      const vi = returnVideo();
      if (vi) {
        clearInterval(interval.videosource);
        if (vi.hasAttribute('src')) {
          const src = vi.getAttribute('src');
          if (src && src !== data.videoSource) {
            log('changeVideoSource', src);
            data.videoSource = src;
            changeTargetQuality(setting.targetQuality);
            showVideoResolution();
            if (returnContentType() === 'tv') checkSwitchedProgram();
          }
        }
      }
    }, 500);
  };

  /**
   * ブロックしたユーザー数を確認する
   * @param {boolean} b trueならdata.ngIdReserveを操作する
   * @returns {number} ブロックしたユーザー数
   */
  const checkBlockedUser = (b) => {
    log('checkBlockedUser', b, data.blockedUserId);
    const sBui = localStorage.getItem('abm_blockedUserIds');
    if (sBui) {
      const aBui = sBui.split(',');
      if (b) {
        if (aBui.length >= 100) {
          data.blockedUserId = aBui[0];
          log('checkBlockedUser', data.blockedUserId, aBui[0], aBui[1]);
        } else {
          data.blockedUserId = '';
        }
      }
      return aBui.length;
    }
    log('checkBlockedUser: not found abm_blockedUserIds', 'warn');
    return 0;
  };

  /**
   * クリックしたとき
   * @param {MouseEvent} e
   */
  const checkClick = (e) => {
    if (!(e.target instanceof HTMLElement)) return;
    const content = returnContentType();
    const targetClass =
      content === 'tv'
        ? selector.commentReportSubmitTv
        : content === 'ts'
          ? selector.commentReportSubmitTs
          : content === 'le'
            ? selector.commentReportSubmitLe
            : '';

    if (targetClass && e.target.closest(`.${targetClass}`)) {
      addNgId();
    }
  };

  /**
   * 右クリックしたとき
   * @param {MouseEvent} e
   */
  const checkContextmenu = (e) => {
    const content = returnContentType();
    if (
      setting.programInfo1 &&
      data.dataProviderRunning &&
      content === 'tv' &&
      !e.altKey &&
      !e.ctrlKey &&
      !e.shiftKey &&
      !e.metaKey &&
      (e.target instanceof HTMLElement || e.target instanceof SVGElement) &&
      e.target.closest(selector.programDetailButton)
    ) {
      e.preventDefault();
      toggleProgramInfo();
    }
  };

  /**
   * ダブルクリックしたとき
   * @param {MouseEvent} e
   */
  const checkDblclick = (e) => {
    if (
      setting.dblclickScroll &&
      /le|ts|vi/.test(returnContentType()) &&
      e.target instanceof HTMLElement &&
      e.target.closest(selector.videoDblclick)
    ) {
      adjustScrollPosition();
    }
  };

  /**
   * キーボードのキーを押したとき
   * @param {KeyboardEvent} e
   */
  const checkKeyDown = (e) => {
    const isInput =
      e.target instanceof HTMLInputElement ||
      e.target instanceof HTMLTextAreaElement ||
      e.target instanceof HTMLSelectElement;
    if (e.shiftKey && e.key === 'O' && (!isInput || (isInput && e.altKey))) {
      openSettings();
    } else if (
      setting.programInfo1 &&
      data.dataProviderRunning &&
      e.shiftKey &&
      e.key === 'P' &&
      (!isInput || (isInput && e.altKey))
    ) {
      toggleProgramInfo();
    } else if (e.key === 'Escape') {
      if (
        e.target instanceof HTMLInputElement &&
        e.target.matches(selector.searchInput)
      ) {
        e.target.blur();
      }
      if (!isInput) closeCommentReportForm();
      const searchResults = document.getElementById(`${sid}_SearchResults`);
      if (
        searchResults &&
        !searchResults.classList.contains(`${sid}_SearchResults-hidden`)
      ) {
        searchResults.classList.add(`${sid}_SearchResults-hidden`);
      }
    } else if (setting.enterKey && e.key === 'Enter') {
      if (!isInput) {
        const ca = document.querySelector(selector.commentArea);
        if (!ca) {
          const cb = document.querySelector(selector.commentButton);
          if (cb instanceof HTMLButtonElement) {
            cb.click();
          }
        }
        const ta = document.querySelector(selector.commentForm);
        if (ta instanceof HTMLTextAreaElement) {
          ta.focus();
          e.preventDefault();
        }
      }
      const cc = document.querySelector(selector.commentContinue);
      if (cc instanceof HTMLButtonElement) {
        const cf = document.querySelector(selector.commentForm);
        if (
          cf instanceof HTMLTextAreaElement &&
          ((isInput && !cf.value) || !isInput)
        ) {
          cc.click();
        }
      }
    } else if (
      setting.sidePanelBackground &&
      e.shiftKey &&
      e.key === 'B' &&
      (!isInput || (isInput && e.altKey))
    ) {
      const style = document.getElementById(`${sid}_style_sidePanelBackground`);
      if (style) removeStyle('sidePanelBackground');
      else addStyle('sidePanelBackground');
    } else if (
      setting.hiddenCommentList &&
      e.shiftKey &&
      e.key === 'C' &&
      (!isInput || (isInput && e.altKey))
    ) {
      const style1 = document.getElementById(`${sid}_style_hiddenCommentList`),
        style2 = document.getElementById(`${sid}_style_hiddenCommentList2`);
      if (!style1 && !style2) {
        addStyle('hiddenCommentList');
      } else if (style1) {
        removeStyle('hiddenCommentList');
        if (setting.hiddenCommentListNum2) addStyle('hiddenCommentList2');
      } else if (style2) {
        removeStyle('hiddenCommentList2');
      }
    } else if (
      e.target instanceof HTMLElement &&
      (e.target.closest(selector.commentReportTv) ||
        e.target.closest(selector.commentReportTs) ||
        e.target.closest(selector.commentReportLe))
    ) {
      if (
        setting.ngIdEnable &&
        e.target.textContent === 'ブロック' &&
        (e.key === 'Enter' || e.key === ' ')
      ) {
        addNgId();
      }
    }
  };

  /**
   * マウスカーソルをコメントリストに重ねたとき
   * @param {MouseEvent} e
   */
  const checkMouseEnter = (e) => {
    const cl = document.querySelector(selector.commentList)?.parentElement;
    if (cl === e.target) data.commentMouseEnter = true;
  };

  /**
   * マウスカーソルをコメントリストから外したとき
   * @param {MouseEvent} e
   */
  const checkMouseLeave = (e) => {
    const cl = document.querySelector(selector.commentList)?.parentElement;
    if (cl === e.target) data.commentMouseEnter = false;
  };

  /**
   * 取得した放送データを確認して新しいチャンネルがあれば通知する
   * @param {any[]} slots
   */
  const checkNewChannels = async (slots) => {
    if (
      !setting.notifyNewChannel ||
      !data.dataProviderRunning ||
      !Array.isArray(slots)
    ) {
      return;
    }

    // 互換性維持: 配列ならオブジェクトに変換
    if (Array.isArray(ls.channelId)) {
      const obj = Object.create(null);
      ls.channelId.forEach((id) => {
        if (typeof id === 'string') obj[id] = Math.floor(Date.now() / 1000);
      });
      ls.channelId = obj;
    } else if (typeof ls.channelId !== 'object' || ls.channelId === null) {
      ls.channelId = Object.create(null);
    }

    if (!ls.discoveryNotified || typeof ls.discoveryNotified !== 'object') {
      ls.discoveryNotified = Object.create(null);
    }

    const currentHistory = ls.channelId;
    let fetchedIds = slots.map((s) => s.channelId);

    if (db?.timetableSlots) {
      const dbChannelIds = await db.timetableSlots
        .orderBy('channelId')
        .uniqueKeys();
      fetchedIds = [...new Set([...fetchedIds, ...dbChannelIds])];
    }

    data.channelId = fetchedIds;

    const newChannels = fetchedIds.filter(
      (id) =>
        id !== '__proto__' &&
        id !== 'constructor' &&
        id !== 'prototype' &&
        !currentHistory[id],
    );
    const toNotifyBefore = [];
    const toNotifyStarted = [];
    const toNotifyDiscovery = [];
    const confirmedIds = [];

    if (newChannels.length > 0) {
      const isFirstRun = Object.keys(currentHistory).length === 0;
      const now = Math.floor(Date.now() / 1000);
      /** @type {any} */
      const table = db?.timetableSlots;

      await Promise.all(
        newChannels.map(async (id) => {
          if (!table) return;
          const pSlots = await table
            .where('channelId')
            .equals(id)
            .filter((/** @type {any} */ s) => s.endAt > now)
            .sortBy('startAt');

          if (pSlots.length > 0) {
            const firstProgram = pSlots[0];
            const notifyTime = firstProgram.startAt - 180;

            if (now >= notifyTime) {
              if (!isFirstRun) {
                const item = {
                  id,
                  hasProgram: true,
                  program: createProgramForNotification(firstProgram),
                };
                if (now < firstProgram.startAt) {
                  toNotifyBefore.push(item);
                } else {
                  toNotifyStarted.push(item);
                }
              }
              confirmedIds.push(id);
              // 通知済み(確定)なので発見通知フラグは削除
              if (ls.discoveryNotified[id]) delete ls.discoveryNotified[id];
            } else {
              const delayMs = (notifyTime - now) * 1000;
              const timerKey = `newChannel_${id}`;
              if (!interval[timerKey]) {
                log(
                  `checkNewChannels: 3分前通知を予約しました (${id})`,
                  new Date(notifyTime * 1000).toLocaleString(),
                );
                interval[timerKey] = setTimeout(() => {
                  showNewChannelNotification(
                    [
                      {
                        id,
                        hasProgram: true,
                        program: createProgramForNotification(firstProgram),
                      },
                    ],
                    'before',
                  );
                  ls.channelId[id] = Math.floor(Date.now() / 1000);
                  if (ls.discoveryNotified[id]) delete ls.discoveryNotified[id];
                  saveStorage();
                  delete interval[timerKey];
                }, delayMs);
              }

              // 過去の番組がない(完全に新しい)かつ、開始まで1時間以上ある場合は発見通知
              if (!isFirstRun && !ls.discoveryNotified[id]) {
                const pastCount = await table
                  .where('channelId')
                  .equals(id)
                  .filter((/** @type {any} */ s) => s.endAt <= now)
                  .count();
                const beforeProgramStart = firstProgram.startAt - 1 * 60 * 60;
                if (pastCount === 0 && beforeProgramStart > now) {
                  toNotifyDiscovery.push({
                    id,
                    hasProgram: true,
                    program: createProgramForNotification(firstProgram),
                  });
                }
              }
            }
          } else if (slots.some((s) => s.channelId === id)) {
            if (!isFirstRun) {
              toNotifyDiscovery.push({ id, hasProgram: false });
            }
            confirmedIds.push(id);
            if (ls.discoveryNotified[id]) delete ls.discoveryNotified[id];
          }
        }),
      );

      // 通知の表示順序: 発見 -> 開始前 -> 開始後

      if (toNotifyDiscovery.length > 0) {
        log(
          'checkNewChannels: 新しいチャンネル(発見)を通知します',
          toNotifyDiscovery,
        );
        showNewChannelNotification(toNotifyDiscovery, 'discovery');
        toNotifyDiscovery.forEach((d) => {
          // 番組情報がある(未来の開始待ち)場合のみ、重複通知防止フラグを立てる
          if (d.hasProgram) ls.discoveryNotified[d.id] = now;
        });
        saveStorage();
      }

      if (toNotifyBefore.length > 0) {
        log(
          'checkNewChannels: 新しいチャンネル(開始前)を通知します',
          toNotifyBefore,
        );
        showNewChannelNotification(toNotifyBefore, 'before');
      }

      if (toNotifyStarted.length > 0) {
        log(
          'checkNewChannels: 新しいチャンネル(開始後)を通知します',
          toNotifyStarted,
        );
        showNewChannelNotification(toNotifyStarted, 'started');
      }
    }

    // 最終的な保存リストを更新
    const nowTs = Math.floor(Date.now() / 1000);
    let changed = false;

    // 今回見つかったチャンネルを更新
    // 注意: 未来の番組通知待ちのチャンネルはここには含めない(含めると既知扱いになりタイマーが消えるため)
    fetchedIds.forEach((id) => {
      // プロトタイプ汚染対策
      if (id === '__proto__' || id === 'constructor' || id === 'prototype') {
        return;
      }

      const isKnown = !!currentHistory[id];
      const isConfirmed = confirmedIds.includes(id);

      // 既に知っているチャンネル、または今回放送開始通知を行ったチャンネルのみ更新
      if (isKnown || isConfirmed) {
        if (currentHistory[id] !== nowTs) {
          currentHistory[id] = nowTs;
          changed = true;
        }
      }
    });

    // 過去3日以上放送がないチャンネルを履歴から削除 (クリーンアップ)
    const expireTs = nowTs - 3 * 24 * 60 * 60; // 3日前
    Object.keys(currentHistory).forEach((id) => {
      if (currentHistory[id] < expireTs) {
        delete currentHistory[id];
        changed = true;
      }
    });

    if (changed) {
      ls.channelId = currentHistory;
      saveStorage();
    }
  };

  /**
   * 新規コメントを読み込んだとき
   */
  const checkNewComments = async () => {
    const content = returnContentType();
    if (!content) return;
    const ca = document.querySelectorAll(selector.commentAll),
      /** @type {NodeListOf<HTMLDivElement>|null} */
      cb = content ? document.querySelectorAll(selector.commentBefore) : null,
      listP =
        content === 'tv' || content === 'ts'
          ? document.querySelector(selector.commentList)?.parentElement
          : content === 'le'
            ? document.querySelector(selector.commentList)?.parentElement
                ?.parentElement
            : null;
    if (!ca?.length || !cb?.length) return;
    data.newComments = true;
    data.commentAll = ca.length;
    if (ca.length === cb.length) {
      data.archiveComments.length = 0;
      data.commentId.clear();
      if (content !== 'ts') data.comment.length = 0;
    }
    if (listP) {
      for (let i = 0, j = cb.length; i < j; i++) {
        /** @type {HTMLDivElement|null} */
        const eInner = cb[i].querySelector(selector.commentInner);
        if (eInner) {
          eInner.dataset[`${sid.toLowerCase()}Hidden`] = 'true';
        }
      }
      if (content === 'ts') {
        data.archiveComments.length = 0;
        await sleep(1000);
        if (setArchiveComments(ca.length - cb.length)) {
          ngComment(cb, listP, content, false);
        } else log('checkNewComments: not found archiveCommentContainer');
      } else ngComment(cb, listP, content, false);
    }
  };

  /**
   * サイドパネルが最初から開いているかを調べる
   */
  const checkSidePanel = () => {
    if (!document.querySelector(`.${sid}_SidePanelCloseed`)) {
      /** @type {HTMLButtonElement|null} */
      const button = document.querySelector(selector.sidePanelClose);
      button?.classList.add(`${sid}_SidePanelCloseed`);
      button?.click();
    }
  };

  /**
   * 検索入力欄があるか調べ、あればイベントリスナーを登録する
   */
  const checkSearchInput = () => {
    if (!setting.searchProgram || !data.dataProviderRunning) return;
    const input = document.querySelector(selector.searchInput);
    if (
      input instanceof HTMLInputElement &&
      !input.dataset[`${sid.toLowerCase()}Search`]
    ) {
      input.dataset[`${sid.toLowerCase()}Search`] = 'true';
      input.addEventListener('keydown', (e) => {
        if (e.key === 'Enter' && !e.shiftKey) {
          const q = input.value.trim();
          if (q) {
            e.preventDefault();
            e.stopPropagation();
            searchProgram(q);
          }
        }
      });
    }

    // 検索履歴や候補のボタンにイベントリスナーを登録
    const suggestButtons = document.querySelectorAll(
      selector.searchSuggestItemButton,
    );
    suggestButtons.forEach((button) => {
      if (button instanceof HTMLButtonElement) {
        button.dataset.littletoolsSuggest = 'true';

        /** @param {MouseEvent|KeyboardEvent} e */
        const handleSuggest = (e) => {
          if (e.shiftKey) return;
          const keyword = button.textContent?.trim();
          if (keyword) {
            e.preventDefault();
            e.stopPropagation();
            searchProgram(keyword);
          }
        };

        // クリックイベント
        button.addEventListener('click', handleSuggest);

        // Enterキーイベント
        button.addEventListener('keydown', (e) => {
          if (e.key === 'Enter') {
            handleSuggest(e);
          }
        });
      }
    });
  };

  /**
   * テレビで番組が切り替わったか調べる
   */
  const checkSwitchedProgram = () => {
    log('checkSwitchedProgram', data.programId);
    let n = 0;
    const check = () => {
      /** @type {HTMLDivElement|null} */
      const screen = document.querySelector(selector.tvScreen),
        id = screen ? getCommentProps(screen, 'programId', 'tv') : '',
        ta = document.querySelector(selector.commentTextarea);
      if (id && typeof id === 'string') {
        if (data.programId !== id) {
          data.programId = id;
          clearInterval(interval.checkSwitchedProgram);
          log('checkSwitchedProgram: 番組切り替えを検知', n);
          if (
            ta instanceof HTMLTextAreaElement &&
            !ta.placeholder.includes(' / ') &&
            !ta.placeholder.includes('視聴数')
          ) {
            ta.placeholder = 'コメントを入力';
          }
          if (setting.programInfo1 || setting.programInfo2) {
            getProgramInfo(id);
          }
        }
      } else {
        const eInfo1 = document.getElementById(`${sid}_ProgramInfo1`),
          eInfo2 = document.getElementById(`${sid}_ProgramInfo2`),
          eCounter = document.querySelector(selector.commentCounter);
        if (eInfo1) eInfo1.innerHTML = '';
        if (eInfo2) eInfo2.innerHTML = '';
        if (eCounter?.textContent === '―') {
          if (ta instanceof HTMLTextAreaElement) {
            ta.placeholder = 'コメントを入力';
          }
          clearInterval(interval.checkSwitchedProgram);
        }
      }
      if (n > 120) clearInterval(interval.checkSwitchedProgram);
      n += 1;
    };
    clearInterval(interval.checkSwitchedProgram);
    interval.checkSwitchedProgram = setInterval(check, 1000);
  };

  /**
   * スクリプトとストレージのバージョンを確認
   */
  const checkVersion = () => {
    if ('version' in ls) {
      if (ls.version < 9) {
        if ('pageKey' in ls) {
          delete ls.pageKey;
          log('delete ls.pageKey');
        }
      }
    }
    ls.version = data.version;
    saveStorage();
  };

  /**
   * 動画のフッターが表示されているかを調べる
   */
  const checkVisibleFooter = () => {
    const fo = document.querySelector(selector.footerVisible);
    if (fo) showVideoResolution();
  };

  /**
   * NGワードを編集して警告対象の正規表現を修正したら警告リストから削除する
   */
  const checkWarningRe = () => {
    if (lsWord.warningRe.length > 0) {
      const currentNgWords = setting.ngWord || '';
      const newWarningRe = lsWord.warningRe.filter((w) =>
        currentNgWords.includes(w),
      );
      if (newWarningRe.length !== lsWord.warningRe.length) {
        lsWord.warningRe = newWarningRe;
        saveStorage();
      }
    }
  };

  /**
   * ブロックアイコンをクリックして表示した報告フォームをすべて閉じる
   */
  const closeCommentReportForm = () => {
    const submit = document.querySelectorAll(selector.commentReportCancel);
    for (let i = 0, j = submit.length; i < j; i++) {
      const button = submit[i];
      if (button instanceof HTMLButtonElement) button.click();
    }
  };

  /**
   * 「次のエピソード」が表示されたらキャンセルボタンを押す
   * 「こちらもオススメ」が表示されたらキャンセルボタンを押す
   * 可能ならスキップボタンを押す
   */
  const closeNextProgramInfo = () => {
    if (setting.nextProgramInfo) {
      const nc = document.querySelector(selector.nextCancel);
      if (nc instanceof HTMLButtonElement) {
        setTimeout(() => {
          nc.click();
        }, 2000);
      }
    }
    if (setting.nextProgramInfo) {
      const ncm = document.querySelector(selector.nextCancelMini);
      if (ncm instanceof HTMLButtonElement) {
        setTimeout(() => {
          ncm.click();
        }, 2000);
      }
    }
    if (setting.recommendedSeriesInfo) {
      const rc = document.querySelector(selector.recommendedCancel);
      if (rc instanceof HTMLButtonElement) {
        setTimeout(() => {
          rc.click();
        }, 2000);
      }
    }
    if (setting.skipVideo) {
      const vs = document.querySelector(selector.videoSkip);
      if (vs instanceof HTMLButtonElement) {
        clearInterval(interval.videoskip);
        interval.videoskip = setInterval(() => {
          const vs2 = document.querySelector(selector.videoSkip2);
          if (vs2 instanceof HTMLButtonElement) {
            clearInterval(interval.videoskip);
            vs2.click();
          } else if (!vs && !vs2) clearInterval(interval.videoskip);
        }, 500);
      }
    }
  };

  /**
   * 一部の通知を閉じる
   */
  const closeNotificationToast = () => {
    log('closeNotificationToast');
    /** @type {HTMLButtonElement|null} */
    const closeButton = document.querySelector(selector.notificationClose),
      /** @type {HTMLSpanElement|null} */
      message = document.querySelector(selector.notificationMessage);
    if (closeButton && message) {
      if (/推奨環境外のため/.test(message.innerText)) {
        closeButton.click();
      }
    }
  };

  /**
   * 設定欄を閉じる
   */
  const closeSettings = () => {
    const settings = document.querySelector(`#${sid}_Settings`);
    settings?.classList.add(`${sid}_Settings_hidden`);
  };

  /**
   * 記入されたNGワードを処理しやすくするため正規表現用とそれ以外用に分ける
   * @param {string} t
   * @returns {string}
   */
  const convertNgword = (t) => {
    log('convertNgword');
    const aWord = t.split(/\r\n|\n|\r/),
      aText = [],
      /** @type {RegExp[]} */
      aRe = [];
    let sError = '';
    for (let i = 0, j = aWord.length; i < j; i++) {
      const str = aWord[i].trim();
      if (str.slice(0, 2) !== '//') {
        if (/^\/.+\/[dgimsuvy]{0,}$/.test(str)) {
          try {
            const re = str.slice(1, str.lastIndexOf('/')),
              flag = str.slice(str.lastIndexOf('/') + 1) || '';
            aRe.push(new RegExp(re, flag));
          } catch (error) {
            sError += `${i + 1}行目: ${str}\n`;
            log(error, 'error');
          }
        } else {
          aText.push(str);
        }
      }
    }
    data.ngWordText = [...aText];
    data.ngWordRe = [...aRe];
    return sError;
  };

  /**
   * 通知用の番組オブジェクトを作成
   * @param {TimetableSlot|BroadcastSlot} p 番組データ
   * @returns {{id: string, title: string, startAt: number, endAt: number, tId?: string, tName?: string, tVersion?: string}}
   */
  const createProgramForNotification = (p) => ({
    id: p.id,
    title: p.title || '',
    startAt: p.startAt || 0,
    endAt: p.endAt || 0,
    tId: p.thumbnails?.default?.id,
    tName: p.thumbnails?.default?.name,
    tVersion: p.thumbnails?.default?.version,
  });

  /**
   * 番組情報の要素を作成
   * @param {object} o ダウンロードした番組情報
   * @param {HTMLElement} [targetElement] 出力先の要素(省略時はProgramInfo1,2へ出力)
   */
  const createProgramInfo = (o, targetElement) => {
    if (!data.dataProviderRunning) return;
    let label = '';
    const lbs = o.labels || [];
    const marks = o.mark || {};
    const hasLast = lbs.includes('last') || marks.last;

    // ラベルの表示順序を定めて構築
    const preLabels = [];
    if (lbs.includes('new') || marks.newcomer) {
      preLabels.push(`<span class="${sid}_ProgramInfo-label-new">新</span>`);
    }
    if (lbs.includes('live') || marks.live) {
      preLabels.push(`<span class="${sid}_ProgramInfo-label-live">生</span>`);
    }
    if (
      lbs.includes('bundle') ||
      lbs.includes('binge') ||
      marks.bingeWatching
    ) {
      preLabels.push(
        `<span class="${sid}_ProgramInfo-label-bundle">一挙</span>`,
      );
    }
    if (
      lbs.includes('pickup') ||
      lbs.includes('pick') ||
      marks.recommendation
    ) {
      preLabels.push(`<span class="${sid}_ProgramInfo-label-pick">注目</span>`);
    }
    if (lbs.includes('first') || marks.first) {
      preLabels.push(`<span class="${sid}_ProgramInfo-label-first">初</span>`);
    }

    if (preLabels.length > 0) {
      label = `<span class="${sid}_ProgramInfo-labels">${preLabels.join('')}</span>`;
    }

    // channelIdはデータに含まれるものを優先、なければURLから取得
    const dir = location.href.split('/'),
      urlChannelId = dir[dir.length - 1],
      channelId = o.channelId || urlChannelId,
      programId = o.id ? o.id : '',
      title = o.title
        ? `
<div class="${sid}_ProgramInfo-title ${sid}_ProgramInfo-text">
<a href="https://abema.tv/channels/${encodeURIComponent(channelId)}/slots/${encodeURIComponent(programId)}" target="_blank">${label}${escapeHTML(o.title)}${
            hasLast
              ? `<span class="${sid}_ProgramInfo-label-last">終</span>`
              : ''
          }</a>
</div>
      `
        : '',
      startEndAt =
        o.startAt && o.endAt
          ? `
<div class="${sid}_ProgramInfo-startEndAt ${sid}_ProgramInfo-text">${returnFormatDate(
              o.startAt,
            )} ~ ${returnFormatDate(o.endAt)}</div>
      `
          : '',
      tsFreeEndAt = o.timeshiftFreeEndAt
        ? `
<div class="${sid}_ProgramInfo-tsFreeEndAt ${sid}_ProgramInfo-text">無料見逃し視聴:${returnFormatDate(
            o.timeshiftFreeEndAt,
          )}まで</div>
      `
        : '',
      tsEndAt = o.timeshiftEndAt
        ? `<div class="${sid}_ProgramInfo-tsEndAt ${sid}_ProgramInfo-text">${
            o.timeshiftFreeEndAt ? '(' : ''
          }ABEMAプレミアム見逃し視聴:${returnFormatDate(
            o.timeshiftEndAt,
          )}まで${o.timeshiftFreeEndAt ? ')' : ''}</div>`
        : '',
      highlight = o.detailHighlight
        ? `
<div class="${sid}_ProgramInfo-detailHighlight ${sid}_ProgramInfo-text">${escapeHTML(o.detailHighlight)}</div>
      `
        : '',
      content = o.content
        ? `
<div class="${sid}_ProgramInfo-content ${sid}_ProgramInfo-text">${escapeHTML(o.content)}</div>
      `
        : '';
    let tsAt = `<div class="${sid}_ProgramInfo-tsAt">`,
      credit1 = '',
      credit2 = '';
    if (o.credit?.casts?.length || o.credit?.crews?.length) {
      credit1 += `<div class="${sid}_ProgramInfo-credit">`;
    }
    if (o.credit?.casts?.length) {
      credit2 += `<div class="${sid}_ProgramInfo-credit2">`;
    }
    if (tsFreeEndAt) tsAt += tsFreeEndAt;
    if (tsEndAt) tsAt += tsEndAt;
    tsAt += '</div>';
    if (o.credit?.casts?.length) {
      credit1 += `<div class="${sid}_ProgramInfo-credit-casts ${sid}_ProgramInfo-text">`;
      if (o.credit.casts.length === 1) {
        credit1 += /^[-‐]$/.test(o.credit.casts[0])
          ? ''
          : `<dl><dt>【キャスト】</dt><dd>${escapeHTML(o.credit.casts[0])}</dd></dl>`;
      } else {
        credit1 += `<dl><dt>【キャスト】</dt><dd>${o.credit.casts
          .map((c) => escapeHTML(c))
          .join('</dd><dd>')}</dd></dl>`;
      }
      credit1 += '</div>';
      credit2 += `<div class="${sid}_ProgramInfo-credit2-casts ${sid}_ProgramInfo-text">`;
      if (o.credit.casts.length === 1) {
        credit2 += /^[-‐]$/.test(o.credit.casts[0])
          ? ''
          : `<dl><dt>【キャスト】</dt><dd>${escapeHTML(o.credit.casts[0])}</dd></dl>`;
      } else {
        credit2 += `<dl><dt>【キャスト】</dt><dd>${o.credit.casts
          .map((c) => escapeHTML(c))
          .join('</dd><dd>')}</dd></dl>`;
      }
      credit2 += '</div>';
    }
    if (o.credit?.crews?.length) {
      credit1 += `<div class="${sid}_ProgramInfo-credit-crews ${sid}_ProgramInfo-text">`;
      if (o.credit.crews.length === 1) {
        credit1 += /^[-‐]$/.test(o.credit.crews[0])
          ? ''
          : `<dl><dt>【スタッフ】</dt><dd>${escapeHTML(o.credit.crews[0])}</dd></dl>`;
      } else {
        credit1 += `<dl><dt>【スタッフ】</dt><dd>${o.credit.crews
          .map((c) => escapeHTML(c))
          .join('</dd><dd>')}</dd></dl>`;
      }
      credit1 += '</div>';
    }
    if (o.credit?.copyrights?.length) {
      credit1 += `<div class="${sid}_ProgramInfo-credit-copyrights ${sid}_ProgramInfo-text">`;
      if (o.credit.copyrights.length === 1) {
        credit1 += `<dl><dt></dt><dd>${escapeHTML(o.credit.copyrights[0])}</dd></dl>`;
      } else {
        credit1 += `<dl><dt></dt><dd>${o.credit.copyrights
          .map((c) => escapeHTML(c))
          .join('</dd><dd>')}</dd></dl>`;
      }
      credit1 += '</div>';
    }
    credit1 += '</div>';
    credit2 += '</div>';
    const sInfo1Content = `${title}${startEndAt}${tsAt}${highlight}${content}${credit1}`,
      sInfo2Content = `${title}${startEndAt}${tsAt}${credit2}`;

    const headerHtml = (id) => `
<div class="${sid}_ProgramInfo-header">
  <div class="${sid}_ProgramInfo-header-title">番組情報</div>
  <div class="${sid}_ProgramInfo-header-buttons">
    <button id="${sid}_${id}-close" title="閉じる">×</button>
  </div>
</div>`;
    const bodyHtml = (info) =>
      `<div class="${sid}_ProgramInfo-body">${info}</div>`;

    const sInfo1 = `${headerHtml('ProgramInfo1')}${bodyHtml(sInfo1Content)}`,
      sInfo2 = `${headerHtml('ProgramInfo2')}${bodyHtml(sInfo2Content)}`;

    // 出力先が指定されている場合(検索詳細など)はヘッダーなしの本体のみを出力
    if (targetElement) {
      targetElement.innerHTML = bodyHtml(sInfo1Content);
      return;
    }

    const info1 = document.getElementById(`${sid}_ProgramInfo1`),
      setupCloseButton = (infoElement, infoId) => {
        document
          .getElementById(`${sid}_${infoId}-close`)
          ?.addEventListener('click', () => {
            infoElement.classList.add(`${sid}_ProgramInfo_hidden`);
          });
      };
    if (info1) {
      info1.innerHTML = sInfo1;
      setupCloseButton(info1, 'ProgramInfo1');
    } else {
      const eInfo1 = document.createElement('div'),
        screen1 = document.querySelector(selector.tvContainerScreen);
      if (screen1) {
        eInfo1.id = `${sid}_ProgramInfo1`;
        eInfo1.className = `${sid}_ProgramInfo_hidden`;
        eInfo1.innerHTML = sInfo1;
        screen1.appendChild(eInfo1);
        setupCloseButton(eInfo1, 'ProgramInfo1');
      } else {
        log('createProgramInfo: not found screen');
      }
    }
    clearTimeout(interval.programInfo2);
    const info2 = document.getElementById(`${sid}_ProgramInfo2`),
      setStyleCasts = (c) => {
        const cast2 = document.querySelector(
          `.${sid}_ProgramInfo-credit2-casts > dl`,
        );
        if (cast2 instanceof HTMLDListElement) {
          cast2.style.gridTemplateColumns = `repeat(auto-fit, minmax(${c}em, 1fr))`;
        }
      },
      showInfo2 = (e) => {
        // ProgramInfo1が表示されていない場合のみ表示するロジックを維持
        const pi1 = document.getElementById(`${sid}_ProgramInfo1`);
        if (!pi1 || pi1.classList.contains(`${sid}_ProgramInfo_hidden`)) {
          e.classList.remove(`${sid}_ProgramInfo_hidden`);
          interval.programInfo2 = setTimeout(
            () => {
              e.classList.add(`${sid}_ProgramInfo_hidden`);
            },
            Number(setting.programInfo2Num) * 1000,
          );
        }
      };
    let chara = 0;
    const creditsCasts = o.credit?.casts || [];
    for (const ele of creditsCasts) {
      if (ele.length > chara) chara = ele.length;
    }
    if (setting.programInfo2) {
      let targetInfoElement = info2;
      if (!targetInfoElement) {
        const screen2 = document.querySelector(selector.tvContainerScreen);
        if (screen2) {
          targetInfoElement = document.createElement('div');
          targetInfoElement.id = `${sid}_ProgramInfo2`;
          targetInfoElement.className = `${sid}_ProgramInfo_hidden`;
          screen2.appendChild(targetInfoElement);
        } else {
          log('createProgramInfo2: not found screen');
        }
      }

      if (targetInfoElement) {
        targetInfoElement.innerHTML = sInfo2;
        setupCloseButton(targetInfoElement, 'ProgramInfo2');
        setStyleCasts(chara);
        showInfo2(targetInfoElement);
      }
    }
  };

  /**
   * 設定欄を作成する
   */
  const createSettings = () => {
    const sSettings = `
<div id="${sid}_Settings-header">
  <div id="${sid}_Settings-header-title">
    <a href="https://greasyfork.org/ja/scripts/465585-abema-little-tools" target="_blank">ABEMA Little Tools 設定</a>
  </div>
  <button id="${sid}_Settings-header-close" title="閉じる">×</button>
</div>
<div id="${sid}_Settings-main">
  <input id="${sid}_Settings-Tab-General" type="radio" name="Tab" class="${sid}_Settings-tab-switch" checked="checked">
  <label class="${sid}_Settings-tab-label" for="${sid}_Settings-Tab-General">全般</label>
  <div id="${sid}_Settings-General" class="${sid}_Settings-tab-content">
    <fieldset>
      <legend>全般</legend>
      <label>
        <input id="${sid}_Settings-reduceNavigation" type="checkbox">
        ページを開いたときサイドナビゲーションを縮める
      </label>
      <br>
      <label title="左側のサイドナビゲーションを縮めているとき、ウィンドウ左端にマウスカーソルを合わせるとサイドナビゲーションを表示します。">
        <input id="${sid}_Settings-mouseoverNavigation" type="checkbox">
        左端にマウスオーバーしたときサイドナビゲーションを表示する
      </label>
      <br>
      <label>
        <input id="${sid}_Settings-closeNotification" type="checkbox">
        右上に表示される一部の通知を閉じる
      </label>
      <br>
      <label title="テレビ・ライブイベントでは最大解像度も表示します。">
        <input id="${sid}_Settings-videoResolution" type="checkbox">
        動画の解像度と表示領域サイズを表示する
      </label>
      <br>
      <label title="ABEMAトップページなどで、ヘッダーは固定表示せずにページのスクロールに追従するようにします。">
        <input id="${sid}_Settings-headerPosition" type="checkbox">
        ヘッダーを追従表示にする
      </label>
      <br>
      <label title="ヘッダー・サイドナビゲーションなどの背景や一部のボタンを半透明にします。">
        <input id="${sid}_Settings-semiTransparent" type="checkbox">
        ヘッダーやサイドナビゲーションなどを半透明にする
      </label>
      <br>
      <label>
        <input id="${sid}_Settings-smallFontSize" type="checkbox">
        ヘッダーやフッターの一部の文字サイズを小さくする
      </label>
      <br>
      <label title="動画の表示幅を縮めずに右側のサイドパネルを動画に重ねて表示します。">
        <input id="${sid}_Settings-overlapSidePanel" type="checkbox">
        サイドパネルを動画に重ねて表示する
      </label>
      <br>
      <label title="「サイドパネルを動画に重ねて表示する」がONのときサイドパネルの背景が透明になりますが、Shift+Bキーで背景を半透明の黒色にします。&#13;&#10;入力欄にフォーカスしているときはAlt+Shift+Bキーで動作します。">
        <input id="${sid}_Settings-sidePanelBackground" type="checkbox">
        Shift+Bキーでサイドパネルの背景を半透明にする
      </label>
      <br>
      <label title="サイドパネルの幅を100px~1000pxに変更できます。&#13;&#10;初期値:320">
        <input id="${sid}_Settings-sidePanelSize" type="checkbox">
        サイドパネルの幅を変更する
        <input id="${sid}_Settings-sidePanelSizeNum" type="number" min="100" max="1000" step="10" ${
          setting.sidePanelSize ? '' : 'disabled'
        }>px
      </label>
      <br>
      <label title="視聴プランはアカウント管理ページで確認できます。">
        <input id="${sid}_Settings-hiddenIdAndPlan" type="checkbox">
        サイドナビゲーションの視聴プランを隠す
      </label>
      <br>
      <label title="チャンネルが追加されたのを検出したらページ右上に通知を表示します。クリックすると通知を閉じます。&#13;&#10;可能であれば、追加されたチャンネルの放送中(もしくは次に放送される予定)の番組情報も合わせて表示します。">
        <input id="${sid}_Settings-notifyNewChannel" type="checkbox">
        チャンネルが追加されたら通知する
        <select id="${sid}_Settings-notifyNewChannelTarget" ${
          setting.notifyNewChannel ? '' : 'disabled'
        }>
          <option value="0">ページ右上に表示</option>
          <option value="1">デスクトップ通知</option>
        </select>
      </label>
    </fieldset>
  </div>
  <input id="${sid}_Settings-Tab-Tv" type="radio" name="Tab" class="${sid}_Settings-tab-switch">
  <label class="${sid}_Settings-tab-label" for="${sid}_Settings-Tab-Tv">テレビ</label>
  <div id="${sid}_Settings-Tv" class="${sid}_Settings-tab-content">
    <fieldset>
      <legend>テレビ</legend>
      <label>
        <input id="${sid}_Settings-closeSidePanel" type="checkbox">
        ページを開いたとき右側のサイドパネルを閉じる
      </label>
      <br>
      <label>
        <input id="${sid}_Settings-sidePanelCloseButton" type="checkbox">
        サイドパネル上端にマウスオーバーしたとき閉じるボタンを表示する
      </label>
      <br>
      <label title="番組情報を開いたとき詳細情報を自動的に表示します。">
        <input id="${sid}_Settings-showProgramDetail" type="checkbox">
        サイドパネルに記載された番組情報の詳細を常に表示する
      </label>
      <br>
      <label title="ボタンにマウスオーバーしたときテキストを表示します。">
        <input id="${sid}_Settings-hiddenButtonText" type="checkbox">
        [最初から見る・番組情報・コメント]ボタンのテキストを隠す
      </label>
      <br>
      <label title="「コメントを入力」の右側に括弧書きで現在の視聴数とコメント数を表示します。">
        <input id="${sid}_Settings-viewCounter" type="checkbox">
        コメント入力欄に視聴数とコメント数を表示する
      </label>
      <br>
      <label title="Shift+Pキー(入力欄にフォーカスしているときはAlt+Shift+Pキー)でも表示/非表示します。&#13;&#10;サイドパネルを閉じずに番組情報を確認できます。">
        <input id="${sid}_Settings-programInfo1" type="checkbox">
        番組情報ボタンを右クリックで独自の番組情報欄を表示/非表示する
      </label>
      <br>
      <label title="次の番組が始まったときやチャンネルを切り替えたときにも番組情報の一部を指定した時間(1~60秒間)表示します。&#13;&#10;初期値:3">
        <input id="${sid}_Settings-programInfo2" type="checkbox">
        番組を見始めたとき番組情報の一部を表示する
        <input id="${sid}_Settings-programInfo2Num" type="number" min="1" max="60" ${
          setting.programInfo2 ? '' : 'disabled'
        }>秒
      </label>
      <br>
      <label title="チャンネルリストに次以降の番組(1~30番組)を追加表示します。放送時間にマウスカーソルを重ねると放送日時をツールチップで表示します。&#13;&#10;また、ほかのチャンネルで次の番組の放送が始まったときは、そのチャンネルの放送中の番組情報を書き換えます。&#13;&#10;初期値:1">
        <input id="${sid}_Settings-nextPrograms" type="checkbox">
        チャンネルリストに次以降の番組を表示する
        <input id="${sid}_Settings-nextProgramsNum" type="number" min="1" max="30" ${
          setting.nextPrograms ? '' : 'disabled'
        }>番組
      </label>
      <br>
      <label title="ページ右上の検索欄から番組を検索して、検索結果をページ遷移せずに表示します。Shift+Enterキーを押したときは通常通りの検索結果ページを開きます。&#13;&#10;詳しい使い方は検索結果の右上に表示される「?」をクリックしてください。">
        <input id="${sid}_Settings-searchProgram" type="checkbox">
        番組の検索結果をページ遷移せずに表示する
      </label>
      <br>
    </fieldset>
  </div>
  <input id="${sid}_Settings-Tab-Video" type="radio" name="Tab" class="${sid}_Settings-tab-switch">
  <label class="${sid}_Settings-tab-label" for="${sid}_Settings-Tab-Video">ビデオ</label>
  <div id="${sid}_Settings-Video" class="${sid}_Settings-tab-content">
    <fieldset>
      <legend>ビデオ・見逃し視聴</legend>
      <label title="自動的に次のエピソードへ移動せずに最後まで再生できるようにします。">
        <input id="${sid}_Settings-nextProgramInfo" type="checkbox">
        再生中に[次のエピソード]が表示されたらキャンセルボタンを押す
      </label>
      <br>
      <label title="自動的にオススメ作品へ移動せずに最後まで再生できるようにします。">
        <input id="${sid}_Settings-recommendedSeriesInfo" type="checkbox">
        再生中に[オススメ作品]が表示されたらキャンセルボタンを押す
      </label>
      <br>
      <label>
        <input id="${sid}_Settings-skipVideo" type="checkbox">
        再生中に可能ならスキップボタンを押す
      </label>
      <br>
    </fieldset>
    <fieldset>
      <legend>ビデオ・見逃し視聴・ライブイベント</legend>
      <label title="デフォルト表示では動画の上や左の余白を減らします。&#13;&#10;ワイド表示では動画の大きさをウィンドウ幅に合わせます。">
        <input id="${sid}_Settings-videoPadding" type="checkbox">
        動画周辺の余白を減らす
      </label>
      <br>
      <label title="可能であれば動画の上や左に隙間がなくなるようにページをスクロールします。">
        <input id="${sid}_Settings-dblclickScroll" type="checkbox">
        動画のコントローラーをダブルクリックしてスクロール位置を調整
      </label>
      <br>
    </fieldset>
  </div>
  <input id="${sid}_Settings-Tab-Comment" type="radio" name="Tab" class="${sid}_Settings-tab-switch">
  <label class="${sid}_Settings-tab-label" for="${sid}_Settings-Tab-Comment">コメント</label>
  <div id="${sid}_Settings-Comment" class="${sid}_Settings-tab-content">
    <fieldset>
      <legend>コメント</legend>
      <label>
        <input id="${sid}_Settings-newCommentOneByOne" type="checkbox">
        コメントを1つずつ表示する
      </label>
      <br>
      <label title="新着コメントが多い場合はスクロールせずに瞬時に表示します。">
        <input id="${sid}_Settings-scrollNewComment" type="checkbox">
        コメントを1つずつスクロールする
      </label>
      <br>
      <label title="いずれかのコメントにマウスカーソルを合わせている間、一時的に新着コメントを表示しません。">
        <input id="${sid}_Settings-stopCommentScroll" type="checkbox">
        コメントにマウスオーバーしたときスクロールを止める
      </label>
      <br>
      <label title="自分のコメントは背景を色付きの半透明で表示します。">
        <input id="${sid}_Settings-highlightNewComment" type="checkbox">
        自分のコメントとテレビでの新規コメントを強調表示する
      </label>
      <br>
      <label title="緑色:初回コメント&#13;&#10;黄色:2~3連続で同内容のコメント&#13;&#10;赤色:4連続以上で同内容のコメント&#13;&#10;紫色:直近5回のいずれかと同内容のコメント">
        <input id="${sid}_Settings-highlightFirstComment" type="checkbox">
        初回コメントと連投コメントを強調表示する
      </label>
      <br>
      <label title="文字サイズを10px~32pxに変更できます。&#13;&#10;初期値:13">
        <input id="${sid}_Settings-commentFontSize" type="checkbox">
        コメントの文字サイズを変更する
        <input id="${sid}_Settings-commentFontSizeNum" type="number" min="10" max="32" ${
          setting.commentFontSize ? '' : 'disabled'
        }>px
      </label>
      <br>
      <label>
        <input id="${sid}_Settings-reduceCommentSpace" type="checkbox">
        コメント周辺の余白を減らす&行間を縮める
      </label>
      <br>
      <label title="コメントの透明度を0%~100%に変更できます。透明度は2つまで指定できます。&#13;&#10;入力欄にフォーカスしているときはAlt+Shift+Cキーで動作します。&#13;&#10;入力欄1の初期値:50">
        <input id="${sid}_Settings-hiddenCommentList" type="checkbox">
        Shift+Cキーでコメントリストを半透明化
        <input id="${sid}_Settings-hiddenCommentListNum" type="number" min="0" max="100" step="5" ${
          setting.hiddenCommentList ? '' : 'disabled'
        }>%
        <input id="${sid}_Settings-hiddenCommentListNum2" type="number" min="0" max="100" step="5" ${
          setting.hiddenCommentList ? '' : 'disabled'
        }>%
      </label>
      <br>
      <label title="各コメント右端のブロックアイコンをクリックすると表示されるフォームをEscキーで閉じます。">
        <input id="${sid}_Settings-escKey" type="checkbox">
        Escキーで[このユーザーをブロックします]をすべてキャンセルする
      </label>
      <br>
      <label title="コメントリストを上へスクロールしたときに表示される[新着コメント↓]ボタンもEnterキーで押します。">
        <input id="${sid}_Settings-enterKey" type="checkbox">
        Enterキーでコメント欄を開く&コメント入力欄にフォーカスする
      </label>
      <br>
      <label title="そのユーザーのコメントを一覧表示します。ブロックする理由の参考にしてください。">
        <input id="${sid}_Settings-reportFormCommentList" type="checkbox">
        コメント報告フォームを開いたときそのユーザーのコメント履歴を表示する
      </label>
      <br>
    </fieldset>
  </div>
  <input id="${sid}_Settings-Tab-Quality" type="radio" name="Tab" class="${sid}_Settings-tab-switch">
  <label class="${sid}_Settings-tab-label" for="${sid}_Settings-Tab-Quality">画質</label>
  <div id="${sid}_Settings-Quality" class="${sid}_Settings-tab-content">
    <fieldset>
      <legend>
        <label title="チェックボックスONで画質機能を有効にします。">
          <input id="${sid}_Settings-qualityEnable" type="checkbox">
          画質
        </label>
      </legend>
      <details>
        <summary>[説明]</summary>
        <p>番組の画質を設定します。</p>
        <p>番組を開いた直後やCM直後などの数秒から数十秒後に設定した画質が反映されます。</p>
        <p>設定した画質が用意されていない番組ではそれよりも低い画質で再生します。</p>
        <p>また、画質の設定に合わせて音質も変更します。</p>
      </details>
      <label>
        画質:
        <select id="${sid}_Settings-targetQuality" ${
          setting.qualityEnable ? '' : 'disabled'
        }>
          <option value="0">自動</option>
          <option value="1">通信節約モード(180p)</option>
          <option value="2">最低画質(240p)</option>
          <option value="3">低画質(360p)</option>
          <option value="4">中画質(480p)</option>
          <option value="5">高画質(720p)</option>
          <option value="6">最高画質(1080p)</option>
        </select>
      </label>
    </fieldset>
  </div>
  <input id="${sid}_Settings-Tab-Ng" type="radio" name="Tab" class="${sid}_Settings-tab-switch">
  <label class="${sid}_Settings-tab-label" for="${sid}_Settings-Tab-Ng">NG</label>
  <div id="${sid}_Settings-Ng" class="${sid}_Settings-tab-content">
    <fieldset>
      <legend>
        <label title="チェックボックスONでNGワード機能を有効にします。">
          <input id="${sid}_Settings-ngWordEnable" type="checkbox">
          NGワード
        </label>
      </legend>
      <details>
        <summary>[説明]</summary>
        <p>NGワードに該当するコメントを表示しません。</p>
        <p>1行に1つのNGワードを記入してください。<br>
        先頭と末尾に / を付けると正規表現として扱います。<br>
        正規表現の末尾に i や u などを追記してフラグを使用できます。</p>
        <p>先頭が // の行はコメントとして扱うのでNGワードとして使用しません。</p>
        <p>記入例:
          <pre>
Aaa
bbb
/Ccc|DDD|eEe/

//大文字小文字を区別しません
/fff|ggg|hhh/i

//10桁以上の数字
/\\d{10,}/

//5文字以上の同じ文字を3回以上繰り返している
//(例:あいうえおあいうえおあいうえお)
/(.{5,})\\1{2,}/

//[ひらがな・カタカナ・漢字・絵文字・全角英数字・一部の全角記号]の
//いずれも含まない(Chromeなど一部のブラウザのみ有効)
/^(?!.*[\\p{scx=Hira}\\p{scx=Kana}\\p{scx=Han}\\p{RGI_Emoji}\\uFF01-\\uFF65]).*$/v
</pre>
        </p>
        <p>NGワードが多すぎると動作が重くなるのでご注意ください。</p>
      </details>
      <textarea id="${sid}_Settings-ngWord" ${
        setting.ngWordEnable ? '' : 'disabled'
      }></textarea>
      <div id="${sid}_Settings-ngWord-error">
        <p>エラー:下記の正規表現を修正してください。</p>
        <pre id="${sid}_Settings-ngWord-error-pre"></pre>
      </div>
      <div id="${sid}_Settings-ngWord-warning">
        <p>注意:下記の正規表現は処理が重いため、修正されるまで無効にしています。</p>
        <pre id="${sid}_Settings-ngWord-warning-pre"></pre>
      </div>
      <label>
        <input id="${sid}_Settings-ngConsole" type="checkbox" ${
          setting.ngWordEnable ? '' : 'disabled'
        }>
        NGワードに該当したコメントをブラウザのコンソールに出力する
      </label>
    </fieldset>
    <fieldset>
      <legend>
        <label title="チェックボックスONでNG ID機能を有効にします。">
          <input id="${sid}_Settings-ngIdEnable" type="checkbox">
          NG ID
        </label>
      </legend>
      <details>
        <summary>[説明]</summary>
        <p>ABEMAではコメント欄からブロックしたユーザーIDをブラウザに100件まで保存していて、それ以上ブロックしたときは古い方から破棄されます。<br>
        このNG ID機能ではその破棄されるユーザーIDを別枠で保存しておいてそのユーザーのコメントもブロックします。</p>
        <p>現在の保存数よりも少ない保存数に変更した場合は古いほうから溢れた分を破棄します。<br>
        0に変更するとすべて破棄します。</p>
      </details>
      <label>
        最大保存数:
        <select id="${sid}_Settings-ngIdMaxSize" ${
          setting.ngIdEnable ? '' : 'disabled'
        }>
          <option value="0">${setting._ngid[0]}</option>
          <option value="1">${setting._ngid[1]}</option>
          <option value="2">${setting._ngid[2]}</option>
          <option value="3">${setting._ngid[3]}</option>
          <option value="4">${setting._ngid[4]}</option>
          <option value="5">${setting._ngid[5]}</option>
          <option value="6">${setting._ngid[6]}</option>
          <option value="7">${setting._ngid[7]}</option>
          <option value="8">${setting._ngid[8]}</option>
          <option value="9">${setting._ngid[9]}</option>
        </select>
        <span id="${sid}_Settings-ngId-record"></span>
      </label>
    </fieldset>
  </div>
</div>
<div id="${sid}_Settings-footer">
  <div id="${sid}_Settings-footer-notice">
    <p>無効化されている設定を利用するには、下記のスクリプトが必要です。</p>
    <p><a href="https://greasyfork.org/ja/scripts/565031-abema-little-tools-data-provider" target="_blank">ABEMA Little Tools - Data Provider</a></p>
  </div>
  <div id="${sid}_Settings-footer-buttons">
    <button id="${sid}_Settings-ok">OK</button>
    <button id="${sid}_Settings-cancel">キャンセル</button>
  </div>
</div>`,
      eSettings = document.createElement('div');
    eSettings.id = `${sid}_Settings`;
    eSettings.className = `${sid}_Settings_hidden`;
    eSettings.innerHTML = sSettings;
    document.body.appendChild(eSettings);
    document
      .getElementById(`${sid}_Settings-overlapSidePanel`)
      ?.addEventListener('change', () => {
        const osp = document.getElementById(`${sid}_Settings-overlapSidePanel`),
          spb = document.getElementById(`${sid}_Settings-sidePanelBackground`);
        if (
          osp instanceof HTMLInputElement &&
          spb instanceof HTMLInputElement
        ) {
          spb.disabled = !osp.checked;
        }
      });
    document
      .getElementById(`${sid}_Settings-sidePanelSize`)
      ?.addEventListener('change', () => {
        const sps = document.getElementById(`${sid}_Settings-sidePanelSize`),
          spsn = document.getElementById(`${sid}_Settings-sidePanelSizeNum`);
        if (
          sps instanceof HTMLInputElement &&
          spsn instanceof HTMLInputElement
        ) {
          spsn.disabled = !sps.checked;
        }
      });
    document
      .getElementById(`${sid}_Settings-notifyNewChannel`)
      ?.addEventListener('change', (event) => {
        const nnc = event.currentTarget;
        const nnct = document.getElementById(
          `${sid}_Settings-notifyNewChannelTarget`,
        );
        if (
          nnc instanceof HTMLInputElement &&
          nnct instanceof HTMLSelectElement
        ) {
          nnct.disabled = !nnc.checked;
        }
      });
    document
      .getElementById(`${sid}_Settings-programInfo2`)
      ?.addEventListener('change', () => {
        const pi2 = document.getElementById(`${sid}_Settings-programInfo2`),
          pi2n = document.getElementById(`${sid}_Settings-programInfo2Num`);
        if (
          pi2 instanceof HTMLInputElement &&
          pi2n instanceof HTMLInputElement
        ) {
          pi2n.disabled = !pi2.checked;
        }
      });
    document
      .getElementById(`${sid}_Settings-nextPrograms`)
      ?.addEventListener('change', () => {
        const np = document.getElementById(`${sid}_Settings-nextPrograms`),
          npn = document.getElementById(`${sid}_Settings-nextProgramsNum`);
        if (np instanceof HTMLInputElement && npn instanceof HTMLInputElement) {
          npn.disabled = !np.checked;
        }
      });
    document
      .getElementById(`${sid}_Settings-newCommentOneByOne`)
      ?.addEventListener('change', () => {
        const snc = document.getElementById(`${sid}_Settings-scrollNewComment`),
          obo = document.getElementById(`${sid}_Settings-newCommentOneByOne`);
        if (
          snc instanceof HTMLInputElement &&
          obo instanceof HTMLInputElement
        ) {
          snc.disabled = !obo.checked;
        }
      });
    document
      .getElementById(`${sid}_Settings-commentFontSize`)
      ?.addEventListener('change', () => {
        const cfs = document.getElementById(`${sid}_Settings-commentFontSize`),
          cfsn = document.getElementById(`${sid}_Settings-commentFontSizeNum`);
        if (
          cfs instanceof HTMLInputElement &&
          cfsn instanceof HTMLInputElement
        ) {
          cfsn.disabled = !cfs.checked;
        }
      });
    document
      .getElementById(`${sid}_Settings-hiddenCommentList`)
      ?.addEventListener('change', () => {
        const hcl = document.getElementById(
            `${sid}_Settings-hiddenCommentList`,
          ),
          hcln = document.getElementById(
            `${sid}_Settings-hiddenCommentListNum`,
          ),
          hcln2 = document.getElementById(
            `${sid}_Settings-hiddenCommentListNum2`,
          );
        if (
          hcl instanceof HTMLInputElement &&
          hcln instanceof HTMLInputElement &&
          hcln2 instanceof HTMLInputElement
        ) {
          hcln.disabled = !hcl.checked;
          hcln2.disabled = !hcl.checked;
        }
      });
    document
      .getElementById(`${sid}_Settings-qualityEnable`)
      ?.addEventListener('change', () => {
        const qe = document.getElementById(`${sid}_Settings-qualityEnable`),
          tq = document.getElementById(`${sid}_Settings-targetQuality`);
        if (qe instanceof HTMLInputElement && tq instanceof HTMLSelectElement) {
          tq.disabled = !qe.checked;
        }
      });
    document
      .getElementById(`${sid}_Settings-ngWordEnable`)
      ?.addEventListener('change', () => {
        const ngwe = document.getElementById(`${sid}_Settings-ngWordEnable`),
          ngw = document.getElementById(`${sid}_Settings-ngWord`),
          ngc = document.getElementById(`${sid}_Settings-ngConsole`);
        if (
          ngwe instanceof HTMLInputElement &&
          ngw instanceof HTMLTextAreaElement &&
          ngc instanceof HTMLInputElement
        ) {
          ngw.disabled = !ngwe.checked;
          ngc.disabled = !ngwe.checked;
        }
      });
    document
      .getElementById(`${sid}_Settings-ngIdEnable`)
      ?.addEventListener('change', () => {
        const ngie = document.getElementById(`${sid}_Settings-ngIdEnable`),
          ngims = document.getElementById(`${sid}_Settings-ngIdMaxSize`);
        if (
          ngie instanceof HTMLInputElement &&
          ngims instanceof HTMLSelectElement
        ) {
          ngims.disabled = !ngie.checked;
        }
      });
    document
      .getElementById(`${sid}_Settings-ok`)
      ?.addEventListener('click', () => {
        const eWord = document.getElementById(`${sid}_Settings-ngWord`),
          eWordError = document.getElementById(`${sid}_Settings-ngWord-error`),
          eWordPre = document.getElementById(
            `${sid}_Settings-ngWord-error-pre`,
          );
        let sError = '';
        if (eWord instanceof HTMLTextAreaElement) {
          const sWord = eWord.value;
          if (sWord) {
            sError = convertNgword(sWord);
          }
        }
        if (
          eWordError instanceof HTMLDivElement &&
          eWordPre instanceof HTMLPreElement
        ) {
          if (sError) {
            const eTabNg = document.getElementById(`${sid}_Settings-Tab-Ng`);
            if (eTabNg instanceof HTMLInputElement) {
              eWordPre.innerText = sError;
              eWordError.style.display = 'block';
              eTabNg.checked = true;
            }
          } else {
            eWordPre.innerText = '';
            eWordError.style.display = 'none';
            saveSettings();
            closeSettings();
          }
        }
      });
    const handleCancel = () => {
      closeSettings();
      loadSettings();
    };
    document
      .getElementById(`${sid}_Settings-cancel`)
      ?.addEventListener('click', handleCancel);
    document
      .getElementById(`${sid}_Settings-header-close`)
      ?.addEventListener('click', handleCancel);
  };

  /**
   * データベースを削除する
   */
  const deleteDatabase = () => {
    if (
      confirm('保存している番組表データや放送中の番組データを削除しますか?')
    ) {
      data.dataProviderRunning = false;
      window.dispatchEvent(new CustomEvent(EVENTS.DELETE_DATABASE));
    }
  };

  /**
   * HTMLエスケープを行う
   * @param {string} str
   * @returns {string}
   */
  const escapeHTML = (str) => {
    if (!str) return '';
    return str.replace(
      /[&<>"']/g,
      (m) =>
        ({
          '&': '&amp;',
          '<': '&lt;',
          '>': '&gt;',
          '"': '&quot;',
          "'": '&#39;',
        })[m] || m,
    );
  };

  /**
   * コメントのプロパティを取得する
   * @param {HTMLElement} e 調べる要素
   * @param {string} k キー名
   * @param {string} c コンテンツ
   * @returns {array|boolean|number|object|string}
   */
  const getCommentProps = (e, k, c = '') => {
    let flag = false;
    if (!e) {
      log('getCommentProps: not found element', k);
      return '';
    }
    for (const key of Object.keys(e)) {
      if (key.startsWith('__reactFiber$')) {
        flag = true;
        if (k === 'id' && /le|tv/.test(c)) {
          if (e[key].return?.pendingProps?.commentId) {
            return e[key].return.pendingProps.commentId;
          }
        }
        if (k === 'isOwnComment' && c === 'le') {
          if ('isOwnComment' in e[key].return.pendingProps) {
            return e[key].return.pendingProps.isOwnComment;
          }
        }
        if (k === 'comments' && c === 'ts') {
          if (e[key].return.pendingProps.comments) {
            return e[key].return.pendingProps.comments;
          }
        }
        if (k === 'commentId' && c === 'ts') {
          if ('commentId' in e[key].return.pendingProps) {
            return e[key].return.pendingProps.commentId;
          }
        }
        if (k === 'commentMessage' && c === 'ts') {
          if ('commentMessage' in e[key].return.pendingProps) {
            return e[key].return.pendingProps.commentMessage;
          }
        }
        if (k === 'programId' && c === 'tv') {
          if (e[key].return?.pendingProps?.slot?.id) {
            return e[key].return.pendingProps.slot.id;
          }
        }
        if (k === 'AdaptationSet') {
          const ma =
            /ts|tv|vi/.test(c) &&
            e[key].return?.stateNode?.player?.getDashAdapter &&
            e[key].return.stateNode.player.getDashAdapter()?.getMpd &&
            e[key].return.stateNode.player.getDashAdapter().getMpd()?.manifest
              ?.Period
              ? e[key].return.stateNode.player.getDashAdapter().getMpd()
                  .manifest
              : c === 'le' &&
                  e[key].return?.pendingProps?.contentSession?._player?._dash
                    ?._player?.getDashAdapter &&
                  e[
                    key
                  ].return.pendingProps.contentSession._player._dash._player.getDashAdapter()
                    ?.getMpd &&
                  e[
                    key
                  ].return.pendingProps.contentSession._player._dash._player
                    .getDashAdapter()
                    .getMpd()?.manifest?.Period
                ? e[
                    key
                  ].return.pendingProps.contentSession._player._dash._player
                    .getDashAdapter()
                    .getMpd().manifest
                : null;
          if (ma?.Period) {
            if (ma.Period.AdaptationSet) return ma.Period.AdaptationSet;
            if (ma.Period instanceof Array) return {};
          }
        }
        if (k === 'abr') {
          const st =
            /ts|tv|vi/.test(c) &&
            e[key].return?.stateNode?.player?.getSettings &&
            e[key].return.stateNode.player.getSettings()?.streaming?.abr
              ? e[key].return.stateNode.player.getSettings()?.streaming
              : c === 'le' &&
                  e[key].return?.pendingProps?.contentSession?._player?._dash
                    ?._player?.getSettings &&
                  e[
                    key
                  ].return.pendingProps.contentSession._player._dash._player.getSettings()
                    ?.streaming?.abr
                ? e[
                    key
                  ].return.pendingProps.contentSession._player._dash._player.getSettings()
                    .streaming
                : null;
          if (st?.abr) return st.abr;
        }
        if (e[key].return?.pendingProps?.comment?.[`_${k}`]) {
          return e[key].return.pendingProps.comment[`_${k}`];
        }
        if (e[key].return?.pendingProps?.commentItem?.[k]) {
          return e[key].return.pendingProps.commentItem[k];
        }
      }
    }
    log('getCommentProps: not found key', k, flag, e);
    return '';
  };

  /*
   * 日付検索などのタイムスタンプ単位(秒 or ミリ秒)と最新日時を取得する
   * @returns {Promise<{scale: number, maxTimeMs: number}>}
   */
  const getDbInfo = async () => {
    try {
      // 最新のデータを1件取得
      const lastItem = await db.timetableSlots.orderBy('startAt').last();
      if (lastItem && lastItem.startAt) {
        // 10000000000 (100億) 未満なら秒単位とみなす
        const scale = lastItem.startAt < 10000000000 ? 0.001 : 1;
        const maxTimeMs = lastItem.startAt * (scale === 1 ? 1 : 1000);
        return { scale, maxTimeMs };
      }
    } catch (e) {
      log('getDbInfo: 失敗', e, 'error');
    }
    return { scale: 1, maxTimeMs: Date.now() };
  };

  /**
   * 画像用のドメインを調べる
   */
  const getImageDomain = () => {
    if (!data.imageDomain) {
      /** @type {NodeListOf<HTMLLinkElement>|null} */
      const link = document.querySelectorAll('link[rel="preconnect"]');
      for (let i = 0, j = link.length; i < j; i++) {
        if (/^https:\/\/image\.[^/]+\.abema-tv\.com\/$/.test(link[i].href)) {
          data.imageDomain = link[i].href;
          log(
            'getImageDomain: imageDomainを自動で設定しました',
            data.imageDomain,
          );
          break;
        }
      }
      if (!data.imageDomain) {
        data.imageDomain = 'https://image.p-c2-x.abema-tv.com/';
        log(
          'getImageDomain: imageDomainを手動で設定しました',
          data.imageDomain,
        );
      }
    }
  };

  /**
   * 現在放送中の番組とそれに続く次の番組の情報を取得する
   */
  const getNextPrograms = async () => {
    if (!data.dataProviderRunning || !db) {
      log(`${dpid} not running or db not found`, 'warn');
      return;
    }

    try {
      const now = Math.floor(Date.now() / 1000);

      // 1分以上経過している場合のみ放送データの更新をリクエスト
      if (now - data.lastBroadcastFetchTime > 60) {
        data.lastBroadcastFetchTime = now;
        window.dispatchEvent(
          new CustomEvent(EVENTS.REQUEST_FETCH_BROADCAST_DATA),
        );
      }

      const num =
        parseInt(/** @type {string} */ (setting.nextProgramsNum)) || 1;

      // 1. 放送中の番組情報を取得
      data.broadcastSlots = await db.broadcastSlots.toArray();

      // 2. 全てのチャンネルを取得
      const channelIds = await db.timetableSlots
        .orderBy('channelId')
        .uniqueKeys();
      const channels = channelIds.map((id) => ({ channelId: id }));
      /** @type {any} */
      const table = db.timetableSlots;

      // 記録用:最短の終了時刻
      let earliestEndAt = 0;

      // 3. 各チャンネルごとに未来の番組を取得
      const nextProgramsData = await Promise.all(
        channels.map(async (ch) => {
          // 現在放送中またはそれ以降の番組を取得
          const slots = await table
            .where('channelId')
            .equals(ch.channelId)
            .filter((/** @type {any} */ s) => s.endAt > now)
            .sortBy('startAt');

          if (slots.length > 0) {
            if (earliestEndAt === 0 || slots[0].endAt < earliestEndAt) {
              earliestEndAt = slots[0].endAt;
            }
          }

          // 次の番組(index 0 は現在放送中なので index 1 以降を対象にする)
          // ただし開始時間が現在時刻より後のものを探す
          const index = slots.findIndex((s) => s.startAt > now);
          const nextSlots = index !== -1 ? slots.slice(index) : [];

          // 番組表データに基づく現在の番組
          const current =
            slots.length > 0 && slots[0].startAt <= now ? slots[0] : null;

          const programs = nextSlots.slice(0, num).map((s) => ({
            title: s.title,
            startAt: s.startAt,
            endAt: s.endAt,
          }));

          return {
            channelId: ch.channelId,
            current: current,
            programs: programs,
          };
        }),
      );

      data.nextPrograms = nextProgramsData;
      log('次の番組データ取得完了:', nextProgramsData);

      if (setting.nextPrograms) showNextPrograms();

      // 自動更新予約
      if (earliestEndAt > now) {
        const delay = (earliestEndAt - now + 2) * 1000;
        const updateTime = new Date(
          earliestEndAt * 1000 + 2000,
        ).toLocaleString();
        clearTimeout(interval.nextPrograms);
        interval.nextPrograms = setTimeout(getNextPrograms, delay);
        log(`次の番組情報の更新予約: ${updateTime}`);
      }
    } catch (e) {
      log(`${dpid}: Failed to get next programs`, e, 'error');
    }
  };

  /**
   * テレビで視聴中の番組情報を取得する
   * @param {string} id 番組ID
   */
  const getProgramInfo = async (id) => {
    /** @type {HTMLDivElement|null} */
    const screen = document.querySelector(selector.tvScreen);
    if (!screen || !data.dataProviderRunning) return;

    /**
     * DBから番組情報を検索する
     * @returns {Promise<any>}
     */
    const searchFromDb = async () => {
      if (!db) return null;
      try {
        const slot = await db.broadcastSlots.get(id);
        return slot;
      } catch (e) {
        log('searchFromDb: error', e, 'error');
        return null;
      }
    };

    log('getProgramInfo: 放送データから番組情報を検索します', id);
    let slot = await searchFromDb();

    if (!slot) {
      log('getProgramInfo: 放送データを能動的に取得します', id);

      // 更新待ち
      await new Promise((resolve) => {
        let timeoutId;
        const handler = () => {
          clearTimeout(timeoutId);
          window.removeEventListener(EVENTS.BROADCAST_DATA_UPDATED, handler);
          resolve(true);
        };
        window.addEventListener(EVENTS.BROADCAST_DATA_UPDATED, handler);
        // 取得要求イベント発火
        window.dispatchEvent(
          new CustomEvent(EVENTS.REQUEST_FETCH_BROADCAST_DATA),
        );
        // タイムアウト5秒
        timeoutId = setTimeout(() => {
          window.removeEventListener(EVENTS.BROADCAST_DATA_UPDATED, handler);
          resolve(false);
        }, 5000);
      });

      // 再検索
      slot = await searchFromDb();
    }

    if (slot) {
      if (id !== data.programId) return;
      log('getProgramInfo: 番組情報が見つかりました', id);
      data.program = { slot: slot };
      createProgramInfo(slot);
    } else {
      log('getProgramInfo: 番組情報が見つかりませんでした', id, 'warn');
    }
  };

  /**
   * 番組ステータス取得用のドメインを調べる
   */
  const getStatsDomain = () => {
    if (!data.statsDomain) {
      /** @type {NodeListOf<HTMLLinkElement>|null} */
      const link = document.querySelectorAll('link[rel="preconnect"]');
      for (let i = 0, j = link.length; i < j; i++) {
        if (/^https:\/\/api\.[^/]+\.abema-tv\.com\/$/.test(link[i].href)) {
          data.statsDomain = link[i].href;
          break;
        }
      }
      if (!data.statsDomain) {
        data.statsDomain = 'https://api.p-c3-e.abema-tv.com/';
      }
    }
  };

  /**
   * 必要ならコメントに色を付ける
   * @param {HTMLDivElement} e1 処理前の新着コメント
   * @param {HTMLDivElement} e2 カスタムデータ属性を付与する新着コメント内の要素
   * @param {String|null|undefined} m コメント本文
   * @param {*} u userID
   * @param {string} t どのページを開いているか
   */
  const highlightComment = (e1, e2, m, u, t) => {
    if (setting.highlightFirstComment) {
      let exists = false,
        duplicate = 0;
      for (let i = 0, j = data.comment.length; i < j; i++) {
        if (data.comment[i].userid === u) {
          exists = true;
          if (m) {
            const mes = m.trim().replace(/(.)\1{3,}$/, '$1$1$1');
            for (let k = 0, l = data.comment[i].message.length; k < l; k++) {
              if (data.comment[i].message[k] === mes) {
                duplicate += 1;
                if (k === 0) {
                  duplicate += 1000;
                } else if (k === 1 && duplicate > 1000) {
                  duplicate += 1000;
                } else if (k === 2 && duplicate > 2000) {
                  duplicate += 1000;
                  break;
                }
              }
            }
            if (data.comment[i].message.length > 4) {
              data.comment[i].message.pop();
            }
            data.comment[i].message.unshift(mes);
          }
          break;
        }
      }
      if (!exists) {
        if (typeof u === 'string' && m) {
          data.comment.push({ userid: u, message: [m] });
          e2.dataset[`${sid.toLowerCase()}Green`] = '';
        }
      } else if (duplicate > 3000) {
        e2.dataset[`${sid.toLowerCase()}Red`] = '';
      } else if (duplicate > 1000) {
        e2.dataset[`${sid.toLowerCase()}Yellow`] = '';
      } else if (duplicate > 0) {
        e2.dataset[`${sid.toLowerCase()}Purple`] = '';
      }
    }
    if (
      setting.highlightNewComment &&
      t === 'le' &&
      getCommentProps(e1, 'isOwnComment', t)
    ) {
      e2.dataset[`${sid.toLowerCase()}Own`] = '';
    }
  };

  /**
   * コメント欄の要素があるか調べる
   */
  const hasCommentElement = () => {
    const check = () => {
      const ca = document.querySelector(selector.commentArea);
      if (ca) {
        clearInterval(interval.comment);
        if (!document.querySelector(`.${sid}_CommentElement`)) {
          setTimeout(() => {
            const cl = ca.querySelector(selector.commentList);
            if (cl) {
              cl.classList.add(`${sid}_CommentElement`);
              if (setting.stopCommentScroll) {
                changeEventListener(true, cl.parentElement, 'commentScroll');
              }
              updateStatsUI();
              observerC.observe(cl, { childList: true });
              checkNewComments();
            } else log('hasCommentElement: Not found element.', 'warn');
          }, 1000);
        }
      } else {
        clearInterval(interval.newcomment);
      }
    };
    clearInterval(interval.comment);
    interval.comment = setInterval(check, 500);
    check();
  };

  /**
   * 通知の要素があるか調べる
   */
  const hasNotification = () => {
    clearInterval(interval.notification);
    interval.notification = setInterval(() => {
      const noti = document.querySelector(selector.notification);
      if (noti) {
        clearInterval(interval.notification);
        closeNotificationToast();
      }
    }, 1000);
  };

  /**
   * サイドナビゲーションの要素があるか調べる
   */
  const hasSideNavigation = () => {
    log('hasSideNavigation');
    clearInterval(interval.navigation);
    const startTime = Date.now();
    interval.navigation = setInterval(() => {
      const navi = document.querySelector(selector.sideNavi);
      if (navi) {
        clearInterval(interval.navigation);
        /**
         * 監視タイマーを停止し、管理変数をリセットする
         */
        const clear = () => {
          clearInterval(timerId);
          if (interval.navigation === timerId) interval.navigation = 0;
        };
        const timerId = setInterval(() => {
          const isClosed =
            navi.classList.contains(selector.sideNaviClosed) ||
            navi.classList.contains(selector.sideNaviWrapClosed);
          if (!isClosed) {
            clear();
            reduceSideNavigation();
          }
        }, 200);
        interval.navigation = timerId;
        setTimeout(clear, 5000);
      } else if (Date.now() - startTime > 10000) {
        clearInterval(interval.navigation);
      }
    }, 200);
  };

  /**
   * VIDEO要素があるか調べる
   */
  const hasVideoElement = () => {
    clearInterval(interval.videoelement);
    interval.videoelement = setInterval(() => {
      const vi = returnVideo();
      if (vi) {
        clearInterval(interval.videoelement);
        if (!vi.classList.contains(`${sid}_VideoElement`)) {
          log('hasVideoElement');
          const content = returnContentType();
          vi.classList.add(`${sid}_VideoElement`);
          observerV.observe(vi, { attributes: true });
          observerR.observe(vi);
          changeTargetQuality(setting.targetQuality);
          if (
            setting.viewCounter &&
            data.dataProviderRunning &&
            data.statsDomain &&
            content === 'tv'
          ) {
            resetStatsPlaceholder(true);
          }
          if (content === 'ts') {
            vi.addEventListener('seeked', seekedVideo);
          }
        }
      }
    }, 500);
  };

  /**
   * ページを開いたときに実行
   */
  const init = () => {
    log('init');
    data.dataProviderRunning = sessionStorage.getItem(RUNNING_KEY) === 'true';
    log(`${dpid} Running:`, data.dataProviderRunning);

    checkVersion();
    setInitialValue();
    if (!document.getElementById(`${sid}_Settings`)) createSettings();
    convertNgword(setting.ngWord);
    checkBlockedUser(true);
    GM_registerMenuCommand('設定', openSettings);
    GM_registerMenuCommand('データベースを削除', deleteDatabase);
    getImageDomain();
    getStatsDomain();
    initStyle();
    if (setting.reduceNavigation) hasSideNavigation();
    if (setting.closeNotification) hasNotification();
    addEventListener(EVENTS.BROADCAST_DATA_UPDATED, () => {
      getNextPrograms();
    });
    addEventListener(EVENTS.CHANNELS_UPDATED, (e) => {
      if (
        e instanceof CustomEvent &&
        e.detail &&
        e.detail.channels &&
        Array.isArray(e.detail.channels)
      ) {
        e.detail.channels.forEach(
          (/** @type {{id: string, name: string}} */ c) => {
            data.channels[c.id] = c.name;
          },
        );
        log(
          'CHANNELS_UPDATED: チャンネルデータを更新しました',
          e.detail.channels.length,
        );
      } else {
        loadChannels();
      }
    });
    addEventListener(EVENTS.STATS_UPDATED, (/** @type {any} */ e) => {
      updateStatsUI(e.detail?.slotId, e.detail?.stats);
    });
    initDataProviderDatabase();
    setTimeout(getNextPrograms, 1000);
    setTimeout(startFirstObserve, 1000);
  };

  /**
   * ページを開いたときに必要な分だけスタイルを追加
   */
  const initStyle = () => {
    addStyle('init');
    if (setting.overlapSidePanel) addStyle('overlapSidePanel');
    if (setting.highlightFirstComment) addStyle('highlightFirstComment');
    if (setting.highlightNewComment) addStyle('highlightNewComment');
    if (setting.sidePanelCloseButton) addStyle('sidePanelCloseButton');
    if (setting.showProgramDetail) addStyle('showProgramDetail');
    if (setting.sidePanelSize) addStyle('sidePanelSize');
    if (setting.hiddenIdAndPlan) addStyle('hiddenIdAndPlan');
    if (setting.hiddenButtonText) addStyle('hiddenButtonText');
    if (setting.videoResolution) addStyle('videoResolution');
    if (setting.semiTransparent) addStyle('semiTransparent');
    if (setting.smallFontSize) addStyle('smallFontSize');
    if (setting.commentFontSize) addStyle('commentFontSize');
    if (setting.nextPrograms) addStyle('nextPrograms');
    if (setting.notifyNewChannel) addStyle('notifyNewChannel');
    if (setting.searchProgram) addStyle('searchProgram');
    if (setting.reduceCommentSpace) addStyle('reduceCommentSpace');
    if (setting.headerPosition && !/tv|tt/.test(returnContentType())) {
      addStyle('headerPosition');
    }
    if (setting.videoPadding) addStyle('videoPadding');
    if (setting.mouseoverNavigation && returnContentType() !== 'tt') {
      addStyle('mouseoverNavigation');
    }
  };

  /**
   * チャンネルデータをロードする
   */
  const loadChannels = async () => {
    if (!db) return;
    try {
      const channels = await db.channels.toArray();
      if (channels.length > 0) {
        data.channels = Object.fromEntries(channels.map((c) => [c.id, c.name]));
        log('loadChannels: チャンネルデータを読み込みました', channels.length);
      }
    } catch (e) {
      log('loadChannels: チャンネルデータの読み込みに失敗', e, 'error');
    }
  };

  /**
   * DataProvider用のデータベースを初期化する
   */
  const initDataProviderDatabase = () => {
    if (typeof Dexie !== 'undefined') {
      db = new Dexie(`${sid}-${dpid}`);
      db.version(1).stores({
        broadcastSlots: 'id, channelId',
        channels: 'id, name',
        timetableSlots:
          'id, channelId, title, startAt, endAt, highlight, timeshiftFreeEndAt',
      });
      loadChannels();
      if (ls.lastCleanupDay) {
        delete ls.lastCleanupDay;
        saveStorage();
      }
    } else {
      log('Dexie.js failed to load', 'error');
    }
  };

  /**
   * 設定を読み込んで設定欄に反映する
   */
  const loadSettings = () => {
    checkWarningRe();
    /**
     * 変数aの型がsとは異なる場合trueを返す
     * @param {any} a 判別したい変数
     * @param {string} t 型
     * @returns {boolean}
     */
    const notType = (a, t) =>
      Object.prototype.toString.call(a).slice(8, -1) !== t ? true : false;
    /**
     * 保存している値を設定欄のチェックボックスに反映する
     * @param {string} s 変数名
     * @param {string} t 型
     */
    const setCheck = (s, t) => {
      const e = document.getElementById(`${sid}_Settings-${s}`);
      if (e instanceof HTMLInputElement && !notType(setting[s], t)) {
        e.checked = setting[s];
      }
    };
    /**
     * 保存している値を設定欄のセレクトボックスに反映する
     * @param {string} s 変数名
     * @param {string} t 型
     */
    const setSelect = (s, t) => {
      const e = document.getElementById(`${sid}_Settings-${s}`);
      if (e) {
        if (e instanceof HTMLSelectElement && !notType(setting[s], t)) {
          e.options.selectedIndex = setting[s];
        }
      }
    };
    /**
     * 保存している値を設定欄の入力ボックス・テキストエリアに反映する
     * @param {string} s 変数名
     * @param {string} t 型
     */
    const setValue = (s, t) => {
      const e = document.getElementById(`${sid}_Settings-${s}`);
      if (e instanceof HTMLTextAreaElement) {
        if (s === 'ngWord' && !notType(lsWord[s], t)) e.value = lsWord[s];
      } else if (e instanceof HTMLInputElement) {
        if (!notType(setting[s], t)) e.value = setting[s];
      }
    };

    // 全般
    setCheck('reduceNavigation', 'Boolean');
    setCheck('mouseoverNavigation', 'Boolean');
    setCheck('closeNotification', 'Boolean');
    setCheck('videoResolution', 'Boolean');
    setCheck('headerPosition', 'Boolean');
    setCheck('semiTransparent', 'Boolean');
    setCheck('smallFontSize', 'Boolean');
    setCheck('overlapSidePanel', 'Boolean');
    setCheck('sidePanelBackground', 'Boolean');
    setCheck('sidePanelSize', 'Boolean');
    setValue('sidePanelSizeNum', 'String');
    setCheck('hiddenIdAndPlan', 'Boolean');
    setCheck('notifyNewChannel', 'Boolean');
    setSelect('notifyNewChannelTarget', 'Number');
    // テレビ
    setCheck('closeSidePanel', 'Boolean');
    setCheck('sidePanelCloseButton', 'Boolean');
    setCheck('showProgramDetail', 'Boolean');
    setCheck('hiddenButtonText', 'Boolean');
    setCheck('viewCounter', 'Boolean');
    setCheck('programInfo1', 'Boolean');
    setCheck('programInfo2', 'Boolean');
    setValue('programInfo2Num', 'String');
    setCheck('nextPrograms', 'Boolean');
    setValue('nextProgramsNum', 'String');
    setCheck('searchProgram', 'Boolean');
    // ビデオ・見逃し視聴
    setCheck('nextProgramInfo', 'Boolean');
    setCheck('recommendedSeriesInfo', 'Boolean');
    setCheck('skipVideo', 'Boolean');
    // ビデオ・見逃し視聴・ライブイベント
    setCheck('videoPadding', 'Boolean');
    setCheck('dblclickScroll', 'Boolean');
    // コメント
    setCheck('newCommentOneByOne', 'Boolean');
    setCheck('scrollNewComment', 'Boolean');
    setCheck('stopCommentScroll', 'Boolean');
    setCheck('highlightNewComment', 'Boolean');
    setCheck('highlightFirstComment', 'Boolean');
    setCheck('commentFontSize', 'Boolean');
    setValue('commentFontSizeNum', 'String');
    setCheck('reduceCommentSpace', 'Boolean');
    setCheck('hiddenCommentList', 'Boolean');
    setValue('hiddenCommentListNum', 'String');
    setValue('hiddenCommentListNum2', 'String');
    setCheck('escKey', 'Boolean');
    setCheck('enterKey', 'Boolean');
    setCheck('reportFormCommentList', 'Boolean');
    // 画質
    setCheck('qualityEnable', 'Boolean');
    setSelect('targetQuality', 'Number');
    // NGワード
    setCheck('ngWordEnable', 'Boolean');
    setValue('ngWord', 'String');
    setCheck('ngConsole', 'Boolean');
    // NG ID
    setCheck('ngIdEnable', 'Boolean');
    setSelect('ngIdMaxSize', 'Number');

    // 全般
    const spb = document.getElementById(`${sid}_Settings-sidePanelBackground`);
    if (spb instanceof HTMLInputElement) {
      spb.disabled = !setting.overlapSidePanel;
    }
    const nnct = document.getElementById(
      `${sid}_Settings-notifyNewChannelTarget`,
    );
    if (nnct instanceof HTMLSelectElement) {
      nnct.disabled = !setting.notifyNewChannel;
    }

    // テレビ
    const pi2n = document.getElementById(`${sid}_Settings-programInfo2Num`);
    if (pi2n instanceof HTMLInputElement) {
      pi2n.disabled = !setting.programInfo2;
    }
    const npn = document.getElementById(`${sid}_Settings-nextProgramsNum`);
    if (npn instanceof HTMLInputElement) {
      npn.disabled = !setting.nextPrograms;
    }

    // コメント
    const snc = document.getElementById(`${sid}_Settings-scrollNewComment`);
    if (snc instanceof HTMLInputElement) {
      snc.disabled = !setting.newCommentOneByOne;
    }

    // 画質
    const tq = document.getElementById(`${sid}_Settings-targetQuality`);
    if (tq instanceof HTMLSelectElement) {
      tq.disabled = !setting.qualityEnable;
    }

    // NGワード
    const ngw = document.getElementById(`${sid}_Settings-ngWord`),
      ngc = document.getElementById(`${sid}_Settings-ngConsole`);
    if (ngw instanceof HTMLTextAreaElement && ngc instanceof HTMLInputElement) {
      ngw.disabled = !setting.ngWordEnable;
      ngc.disabled = !setting.ngWordEnable;
    }
    const ngwe = document.getElementById(`${sid}_Settings-ngWord-error`),
      ngwep = document.getElementById(`${sid}_Settings-ngWord-error-pre`);
    if (ngwe instanceof HTMLDivElement && ngwep instanceof HTMLPreElement) {
      ngwep.innerText = '';
      ngwe.style.display = 'none';
    }
    const ngww = document.getElementById(`${sid}_Settings-ngWord-warning`),
      ngwwp = document.getElementById(`${sid}_Settings-ngWord-warning-pre`);
    if (ngww instanceof HTMLDivElement && ngwwp instanceof HTMLPreElement) {
      // ローカルストレージから処理が重いNGワードを読み込む
      if (lsWord.warningRe.length) {
        ngwwp.innerText = lsWord.warningRe.join('\n');
        ngww.style.display = 'block';
      } else {
        ngwwp.innerText = '';
        ngww.style.display = 'none';
      }
    }

    // NG ID
    const ngims = document.getElementById(`${sid}_Settings-ngIdMaxSize`);
    if (ngims instanceof HTMLSelectElement) {
      ngims.disabled = !setting.ngIdEnable;
    }
    const record = document.getElementById(`${sid}_Settings-ngId-record`);
    if (record instanceof HTMLSpanElement) {
      record.textContent = data.ngId.size
        ? `(現在の保存数:${data.ngId.size})`
        : '';
    }

    if (!data.dataProviderRunning) {
      [
        'notifyNewChannel',
        'viewCounter',
        'programInfo1',
        'programInfo2',
        'nextPrograms',
        'searchProgram',
      ].forEach((s) => {
        const e = document.getElementById(`${sid}_Settings-${s}`);
        if (e instanceof HTMLInputElement) {
          e.disabled = true;
          const label = e.parentElement;
          if (label instanceof HTMLLabelElement) {
            label.title += `\n\nこの機能の利用には${dpid}スクリプトが必要です。`;
            label.style.color = 'gray';
          }
        }
      });
      const notice = document.getElementById(`${sid}_Settings-footer-notice`);
      if (notice instanceof HTMLDivElement) {
        notice.style.display = 'block';
      }
    }
  };

  /**
   * デバッグ用ログ
   * @param {...any} a
   */
  const log = (...a) => {
    if (ls.debug) {
      try {
        if (/^debug$|^error$|^info$|^warn$/.test(a[a.length - 1])) {
          const b = a.pop();
          console[b](sid, ...a);
        } else console.log(sid, ...a);
      } catch (e) {
        if (e instanceof Error) console.error(e.message, ...a);
        else if (typeof e === 'string') console.error(e, ...a);
        else console.error('log error', ...a);
      }
    }
  };

  /**
   * 該当するコメントをNG処理する
   * @param {NodeListOf<HTMLDivElement>} n 処理前の新着コメント一覧
   * @param {HTMLElement} e コメントリストの親要素
   * @param {string} t どのページを開いているか
   * @param {boolean} r true:見逃し視聴でuserID未登録コメントのブロックアイコンをクリックしたとき
   */
  const ngComment = (n, e, t, r) => {
    for (let i = 0; i < n.length; i++) {
      const eMessage = n[i].querySelector(selector.commentMessage),
        /** @type {HTMLDivElement|null} */
        eInner = n[i].querySelector(selector.commentInner),
        message = eMessage?.textContent;
      let cid = undefined,
        ngFlag = false,
        userId = undefined;
      //コメントIDとユーザーIDを取得
      if (/le|tv/.test(t)) {
        cid = getCommentProps(n[i], 'id', t);
      } else if (t === 'ts') {
        cid = getCommentProps(n[i], 'commentId', t);
        if (eInner) {
          if (`${sid.toLowerCase()}UserId` in eInner.dataset) {
            userId = eInner.dataset[`${sid.toLowerCase()}UserId`];
          } else {
            for (let j = 0, k = data.archiveComments.length; j < k; j++) {
              if (data.archiveComments[j].id === cid) {
                userId = data.archiveComments[j].userId;
                eInner.dataset[`${sid.toLowerCase()}UserId`] = userId;
                break;
              }
            }
            if (!userId) {
              log('checkNewComments: not found userId', cid, message);
            }
          }
        }
      }
      const uid = t === 'ts' ? userId : getCommentProps(n[i], 'userId');
      if (!eInner || !cid) continue;
      if (!data.commentId.has(cid)) {
        data.commentId.add(cid);
        //NG IDの処理
        if (!ngFlag && setting.ngIdEnable && uid) {
          if (!(`${sid.toLowerCase()}Ngid` in eInner.dataset)) {
            if (data.ngId.has(uid)) {
              ngFlag = true;
              log(`NG ID: ${uid} / ${cid} / ${message}`);
              eInner.dataset[`${sid.toLowerCase()}Ngid`] = '';
            }
          }
        }
        //NGワードの処理
        if (
          !r &&
          !ngFlag &&
          setting.ngWordEnable &&
          message &&
          !(`${sid.toLowerCase()}Ngword` in eInner.dataset)
        ) {
          for (let j = 0, k = data.ngWordText.length; j < k; j++) {
            if (data.ngWordText[j] && message.includes(data.ngWordText[j])) {
              ngFlag = true;
              eInner.dataset[`${sid.toLowerCase()}Ngword`] = '';
              if (setting.ngConsole) {
                console.log(
                  `${sid} NG Word: ${data.ngWordText[j]} / Comment: ${message} / UserID: ${uid}`,
                );
              }
              break;
            }
          }
          if (!ngFlag) {
            for (let j = 0; j < data.ngWordRe.length; j++) {
              const re = data.ngWordRe[j];
              re.lastIndex = 0;
              const startTime = performance.now();
              const isMatch = re.test(message);
              const endTime = performance.now();
              const duration = endTime - startTime;
              if (duration > 10) {
                console.warn(
                  `${sid}: 正規表現の処理に時間がかかりすぎているため、このNGワードを一時的に無効化しました。 (${duration.toFixed(
                    2,
                  )}ms): ${re}`,
                );
                // 重いNGワードとして記録
                const reStr = re.toString();
                // すでにある場合は追加しない
                if (!lsWord.warningRe.includes(reStr)) {
                  lsWord.warningRe.push(reStr);
                  saveStorage();
                }
                data.ngWordRe.splice(j, 1);
                j -= 1;
                continue;
              }
              if (isMatch) {
                ngFlag = true;
                eInner.dataset[`${sid.toLowerCase()}Ngword`] = '';
                if (setting.ngConsole) {
                  re.lastIndex = 0;
                  const ng = re.exec(message);
                  if (ng) {
                    console.log(
                      `${sid} NG Word: ${ng[0]} / Comment: ${ng.input} / UserID: ${uid}`,
                    );
                  }
                }
                break;
              }
            }
          }
        }
      } else if (t !== 'ts') {
        //重複コメントの処理
        ngFlag = true;
        if (!(`${sid.toLowerCase()}Duplicate` in eInner.dataset)) {
          log(`---------- Duplicate: ${uid} / ${cid} / ${message} ----------`);
          eInner.dataset[`${sid.toLowerCase()}Duplicate`] = '';
        }
      }
      if (ngFlag) eInner.dataset[`${sid.toLowerCase()}Hidden`] = '';
      if (uid) highlightComment(n[i], eInner, message, uid, t);
    }
    if (!r) {
      if (t !== 'ts') {
        const dupli = document.querySelectorAll(selector.commentDuplicate);
        for (let i = 0, j = dupli.length; i < j; i++) {
          const inner = dupli[i].firstChild;
          if (inner) inner.remove();
        }
      }
      visibleComment(e, t);
    }
  };

  /**
   * タイムスタンプをミリ秒に正規化する
   * @param {number} ts
   * @returns {number}
   */
  const normalizeTimestamp = (ts) => {
    return ts < 10000000000 ? ts * 1000 : ts;
  };

  /**
   * 設定欄を開く
   */
  const openSettings = () => {
    const settings = document.querySelector(`#${sid}_Settings`);
    if (settings && settings.classList.contains(`${sid}_Settings_hidden`)) {
      checkWarningRe();
      loadSettings();
      settings.classList.remove(`${sid}_Settings_hidden`);
    }
  };

  /**
   * 日付文字列を解析してTimeRangeを返すヘルパー
   * @param {string} str
   * @param {number} [refYear]
   * @returns {{start: number, end: number}|null}
   */
  const parseDateKey = (str, refYear) => {
    if (!/^\d+$/.test(str)) return null;

    const now = new Date();
    let year,
      month,
      day,
      hour = 0;
    let endHour = 23;

    switch (str.length) {
      case 4: // MMDD
        year = refYear !== undefined ? refYear : now.getFullYear();
        month = parseInt(str.slice(0, 2)) - 1;
        day = parseInt(str.slice(2, 4));
        break;
      case 6: // MMDDHH
        year = refYear !== undefined ? refYear : now.getFullYear();
        month = parseInt(str.slice(0, 2)) - 1;
        day = parseInt(str.slice(2, 4));
        hour = parseInt(str.slice(4, 6));
        endHour = hour;
        break;
      case 8: // YYYYMMDD
        year = parseInt(str.slice(0, 4));
        month = parseInt(str.slice(4, 6)) - 1;
        day = parseInt(str.slice(6, 8));
        break;
      case 10: // YYYYMMDDHH
        year = parseInt(str.slice(0, 4));
        month = parseInt(str.slice(4, 6)) - 1;
        day = parseInt(str.slice(6, 8));
        hour = parseInt(str.slice(8, 10));
        endHour = hour;
        break;
      default:
        return null;
    }

    return {
      start: new Date(year, month, day, hour, 0, 0, 0).getTime(),
      end: new Date(year, month, day, endHour, 59, 59, 999).getTime(),
    };
  };

  /**
   * キューにある通知を処理して表示する
   */
  const processNotificationQueue = () => {
    if (isNotificationShowing || notificationQueue.length === 0) return;

    const item = notificationQueue.shift();
    if (!item) return;

    const NOTIFICATION_ANIMATION_DURATION = 500;
    const { details, headerText } = item;
    isNotificationShowing = true;

    // 既存の要素があれば削除(念のため)
    const existing = document.querySelector(`.${sid}_NotifyNewChannel`);
    if (existing) existing.remove();

    const notification = document.createElement('div');
    notification.className = `${sid}_NotifyNewChannel`;

    notification.addEventListener('click', () => {
      notification.classList.remove(`${sid}_NotifyNewChannel--shown`);
      // アニメーション完了を待ってから次を表示
      setTimeout(() => {
        notification.remove();
        isNotificationShowing = false;
        processNotificationQueue();
      }, NOTIFICATION_ANIMATION_DURATION);
    });

    document.body.appendChild(notification);

    const noProgram = details.filter((d) => !d.hasProgram);
    const withProgram = details.filter((d) => d.hasProgram);

    const noProgramHtml =
      noProgram.length > 0
        ? `
<div class="${sid}_NotifyNewChannel__images">
  ${noProgram
    .map(
      (d) => `
    <img class="${sid}_NotifyNewChannel__img" src="${data.imageDomain}image/channels/${escapeHTML(d.id)}/logo.png?height=60&width=160" title="${escapeHTML(d.id)}">
  `,
    )
    .join('')}
</div>
`
        : '';

    let html = `
<div class="${sid}_NotifyNewChannel__header">
  <div class="${sid}_NotifyNewChannel__text">${escapeHTML(headerText)}</div>
</div>
${noProgramHtml}
    `;

    withProgram.forEach((d) => {
      if (!d.program) return;
      const title = escapeHTML(d.program.title);
      const time = `${returnFormatDate(
        d.program.startAt,
        'date',
      )}<br>${returnFormatDate(
        d.program.startAt,
        'time',
      )}~${returnFormatDate(d.program.endAt, 'time')}`;
      const fullTime = `${returnFormatDate(
        d.program.startAt,
      )} 〜 ${returnFormatDate(d.program.endAt)}`;
      const tooltip = `${title}\n${escapeHTML(fullTime)}`;

      html += `
<div class="${sid}_NotifyNewChannel__item" title="${tooltip}">
  <img class="${sid}_NotifyNewChannel__img" src="${data.imageDomain}image/channels/${escapeHTML(d.id)}/logo.png?height=48&width=128">
  <div class="${sid}_NotifyNewChannel__program-title">${title}</div>
  <div class="${sid}_NotifyNewChannel__program-time">${time}</div>
</div>
      `;
    });

    notification.innerHTML = html;

    requestAnimationFrame(() => {
      notification.classList.add(`${sid}_NotifyNewChannel--shown`);
    });
  };

  /**
   * サイドナビゲーションを縮める
   */
  const reduceSideNavigation = () => {
    log('reduceSideNavigation');
    /** @type {HTMLButtonElement|null} */
    const headerMenuButton = document.querySelector(selector.headerMenuButton);
    headerMenuButton?.click();
  };

  /**
   * 報告フォームの下にそのユーザーのコメント一覧を追加する
   * @param {HTMLFormElement} e ブロックアイコンをクリックして表示される報告フォームの要素
   * @param {string} t どのページを開いているか
   * @param {*} u ブロックアイコンをクリックしたコメントのuserID
   */
  const reportformUserComment = (e, t, u) => {
    log('reportformUserComment', t, u);
    const list = document.querySelectorAll(selector.commentAll),
      comments = [],
      ids = new Set();
    const addComment = (message, id) => {
      if (message && typeof message === 'string' && !ids.has(id)) {
        comments.push(message);
        ids.add(id);
      }
    };
    for (let i = 0, j = list.length; i < j; i++) {
      const co = list[i];
      if (co instanceof HTMLDivElement || co instanceof HTMLLIElement) {
        if (/le|tv/.test(t)) {
          if (getCommentProps(co, 'userId') === u) {
            addComment(
              getCommentProps(co, t === 'tv' ? 'message' : 'body'),
              getCommentProps(co, 'id', t),
            );
          }
        } else if (t === 'ts') {
          const p = co.querySelector('p');
          if (p instanceof HTMLParagraphElement) {
            if (p.dataset[`${sid.toLowerCase()}UserId`] === u) {
              addComment(
                getCommentProps(co, 'commentMessage', t),
                getCommentProps(co, 'commentId', t),
              );
            }
          }
        }
      }
    }
    if (comments.length) {
      const eWrapper = document.createElement('div'),
        eHeader = document.createElement('div'),
        eList = document.createElement('div');
      eWrapper.id = `${sid}_CommentReportForm-NgComment`;
      eHeader.id = `${sid}_CommentReportForm-NgCommentHeader`;
      eHeader.textContent = 'このユーザーのコメント履歴:';
      eList.id = `${sid}_CommentReportForm-NgCommentList`;
      eWrapper.appendChild(eHeader);
      for (let i = 0, j = comments.length; i < j; i++) {
        const p = document.createElement('p');
        p.textContent = comments[i];
        eList.appendChild(p);
      }
      eWrapper.appendChild(eList);
      e.appendChild(eWrapper);
    }
  };

  /**
   * 視聴数・コメント数表示用のプレースホルダーをリセットする
   * @param {boolean} [f=true] true: 'コメントを入力' に戻す, false: 空にする
   */
  const resetStatsPlaceholder = (f = true) => {
    const ta = document.querySelector(selector.commentTextarea);
    if (ta instanceof HTMLTextAreaElement) {
      if (f) {
        if (
          !ta.placeholder.includes(' / ') &&
          !ta.placeholder.includes('視聴数')
        ) {
          ta.placeholder = 'コメントを入力';
        }
      } else {
        ta.placeholder = '';
      }
    }
  };

  /**
   * 動画の大きさが変わったとき
   */
  const resizeVideo = () => {
    clearTimeout(interval.resizeVideo);
    interval.resizeVideo = setTimeout(() => {
      changeTargetQuality(setting.targetQuality);
      checkVisibleFooter();
    }, 500);
  };

  /**
   * スタイルを削除する
   * @param {string} s スタイルの設定名
   */
  const removeStyle = (s) => {
    const e = document.getElementById(`${sid}_style_${s}`);
    if (e instanceof HTMLStyleElement) e.remove();
  };

  /**
   * スタイルを追加・削除する
   * @param {string} s スタイルの設定名
   * @param {boolean|string|number} b
   */
  const reStyle = (s, b) => {
    removeStyle(s);
    if (b) addStyle(s);
  };

  /**
   * どのコンテンツを表示しているかを返す
   * @returns {string} tv:テレビ, ts:見逃し視聴, le:ライブイベント, vi:ビデオ, tt:番組表
   */
  const returnContentType = () => {
    const type = /^https:\/\/abema\.tv\/now-on-air\/.+$/.test(location.href)
      ? 'tv'
      : /^https:\/\/abema\.tv\/channels\/.+$/.test(location.href)
        ? 'ts'
        : /^https:\/\/abema\.tv\/live-event\/.+$/.test(location.href)
          ? 'le'
          : /^https:\/\/abema\.tv\/video\/episode\/.+$/.test(location.href)
            ? 'vi'
            : /^https:\/\/abema\.tv\/timetable/.test(location.href)
              ? 'tt'
              : '';
    return type;
  };

  /**
   * 日時に変換した文字列を返す
   * @param {number|Date} d
   * @param {string} [t]
   * @returns {string}
   */
  const returnFormatDate = (d, t) => {
    if (!(d instanceof Date)) {
      if (d < 10000000000) d = d * 1000;
      d = new Date(d);
    }
    const month = (d.getMonth() + 1).toString(),
      day = d.getDate().toString(),
      week = ['日', '月', '火', '水', '木', '金', '土'][d.getDay()],
      hours = d.getHours().toString().padStart(2, '0'),
      minutes = d.getMinutes().toString().padStart(2, '0');
    if (t === 'time') return `${hours}:${minutes}`;
    if (t === 'date') return `${month}月${day}日 (${week})`;
    return `${month}月${day}日 (${week}) ${hours}:${minutes}`;
  };

  /**
   * video要素を返す
   * @returns {HTMLVideoElement|null}
   */
  const returnVideo = () => {
    /** @type {HTMLVideoElement|null} */
    const vi = document.querySelector(selector.video);
    return vi ? vi : null;
  };

  /**
   * 動画のvideoTracksを返す
   * @returns {object|undefined}
   */
  const returnVideoTracks = () => {
    const vc = document.querySelector(selector.videoContainer);
    if (vc) {
      for (const key of Object.keys(vc)) {
        if (
          key.startsWith('__reactFiber$') &&
          vc[key].return?.stateNode?.player?.videoTracks?.[0]
        ) {
          return vc[key].return.stateNode.player.videoTracks[0];
        }
      }
    }
    return undefined;
  };

  /**
   * 設定を保存する
   */
  const saveSettings = () => {
    /**
     * 設定欄のチェックボックスの値を取得する
     * @param {string} s 変数名
     */
    const getCheck = (s) => {
      const e = document.getElementById(`${sid}_Settings-${s}`);
      if (e instanceof HTMLInputElement) {
        setting[s] = e.checked;
      }
    };
    /**
     * 設定欄のセレクトボックスの値を取得する
     * @param {string} s 変数名
     */
    const getSelect = (s) => {
      const e = document.getElementById(`${sid}_Settings-${s}`);
      if (e instanceof HTMLSelectElement) {
        const index = e.options.selectedIndex;
        if (Number.isInteger(index) && index >= 0) {
          setting[s] = index;
        }
      }
    };
    /**
     * 設定欄の入力ボックス・テキストエリアの値を取得する
     * @param {string} s 変数名
     */
    const getValue = (s) => {
      const e = document.getElementById(`${sid}_Settings-${s}`);
      if (e instanceof HTMLInputElement || e instanceof HTMLTextAreaElement) {
        if (s === 'commentFontSizeNum') {
          if (!e.value || isNaN(Number(e.value)) || /e/.test(e.value)) {
            e.value = '13';
          } else if (Number(e.value) < 10) e.value = '10';
          else if (Number(e.value) > 32) e.value = '32';
        } else if (
          s === 'hiddenCommentListNum' ||
          s === 'hiddenCommentListNum2'
        ) {
          if (!e.value || isNaN(Number(e.value)) || /e/.test(e.value)) {
            if (s === 'hiddenCommentListNum') e.value = '50';
            else if (s === 'hiddenCommentListNum2') e.value = '';
          } else if (Number(e.value) < 0) e.value = '0';
          else if (Number(e.value) > 100) e.value = '100';
        } else if (s === 'programInfo2Num') {
          if (!e.value || isNaN(Number(e.value)) || /e/.test(e.value)) {
            e.value = '3';
          } else if (Number(e.value) < 1) e.value = '1';
          else if (Number(e.value) > 60) e.value = '60';
        } else if (s === 'sidePanelSizeNum') {
          if (!e.value || isNaN(Number(e.value)) || /e/.test(e.value)) {
            e.value = '320';
          } else if (Number(e.value) < 100) e.value = '100';
          else if (Number(e.value) > 1000) e.value = '1000';
        }
        setting[s] = e.value ? e.value : '';
      }
    };
    const content = returnContentType();
    document.getElementById(`${sid}_Settings-ok`)?.blur();

    // 全般
    getCheck('reduceNavigation');
    getCheck('mouseoverNavigation');
    getCheck('closeNotification');
    getCheck('videoResolution');
    getCheck('headerPosition');
    getCheck('semiTransparent');
    getCheck('smallFontSize');
    getCheck('overlapSidePanel');
    getCheck('sidePanelBackground');
    getCheck('sidePanelSize');
    getValue('sidePanelSizeNum');
    getCheck('hiddenIdAndPlan');
    getCheck('notifyNewChannel');
    getSelect('notifyNewChannelTarget');
    // テレビ
    getCheck('closeSidePanel');
    getCheck('sidePanelCloseButton');
    getCheck('showProgramDetail');
    getCheck('hiddenButtonText');
    getCheck('viewCounter');
    getCheck('programInfo1');
    getCheck('programInfo2');
    getValue('programInfo2Num');
    getCheck('nextPrograms');
    getValue('nextProgramsNum');
    getCheck('searchProgram');
    // ビデオ・見逃し視聴
    getCheck('nextProgramInfo');
    getCheck('recommendedSeriesInfo');
    getCheck('skipVideo');
    // ビデオ・見逃し視聴・ライブイベント
    getCheck('videoPadding');
    getCheck('dblclickScroll');
    // コメント
    getCheck('newCommentOneByOne');
    getCheck('scrollNewComment');
    getCheck('stopCommentScroll');
    getCheck('highlightNewComment');
    getCheck('highlightFirstComment');
    getCheck('commentFontSize');
    getValue('commentFontSizeNum');
    getCheck('reduceCommentSpace');
    getCheck('hiddenCommentList');
    getValue('hiddenCommentListNum');
    getValue('hiddenCommentListNum2');
    getCheck('escKey');
    getCheck('enterKey');
    getCheck('reportFormCommentList');
    // 画質
    getCheck('qualityEnable');
    getSelect('targetQuality');
    // NGワード
    getCheck('ngWordEnable');
    getValue('ngWord');
    getCheck('ngConsole');
    // NG ID
    getCheck('ngIdEnable');
    getSelect('ngIdMaxSize');

    // 全般
    ls.reduceNavigation = setting.reduceNavigation;
    if (ls.hiddenButtonText !== setting.hiddenButtonText) {
      reStyle('hiddenButtonText', setting.hiddenButtonText);
    }
    if (ls.mouseoverNavigation !== setting.mouseoverNavigation) {
      reStyle('mouseoverNavigation', setting.mouseoverNavigation);
    }
    if (content === 'tt') removeStyle('mouseoverNavigation');
    ls.mouseoverNavigation = setting.mouseoverNavigation;
    ls.closeNotification = setting.closeNotification;
    if (ls.videoResolution !== setting.videoResolution) {
      reStyle('videoResolution', setting.videoResolution);
    }
    ls.videoResolution = setting.videoResolution;
    if (ls.headerPosition !== setting.headerPosition) {
      reStyle('headerPosition', setting.headerPosition);
    }
    if (/tv|tt/.test(content)) removeStyle('headerPosition');
    ls.headerPosition = setting.headerPosition;
    if (ls.semiTransparent !== setting.semiTransparent) {
      reStyle('semiTransparent', setting.semiTransparent);
    }
    ls.semiTransparent = setting.semiTransparent;
    if (ls.smallFontSize !== setting.smallFontSize) {
      reStyle('smallFontSize', setting.smallFontSize);
    }
    ls.smallFontSize = setting.smallFontSize;
    if (ls.overlapSidePanel !== setting.overlapSidePanel) {
      reStyle('overlapSidePanel', setting.overlapSidePanel);
      if (setting.highlightNewComment) reStyle('highlightNewComment', true);
      if (setting.highlightFirstComment) reStyle('highlightFirstComment', true);
    }
    ls.overlapSidePanel = setting.overlapSidePanel;
    if (ls.sidePanelBackground !== setting.sidePanelBackground) {
      reStyle('sidePanelBackground', setting.sidePanelBackground);
    }
    ls.sidePanelBackground = setting.sidePanelBackground;
    if (ls.sidePanelSize || ls.sidePanelSize !== setting.sidePanelSize) {
      reStyle('nextPrograms', setting.nextPrograms);
      reStyle('overlapSidePanel', setting.overlapSidePanel);
      reStyle('sidePanelSize', setting.sidePanelSize);
      reStyle('searchProgram', setting.searchProgram);
    }
    ls.sidePanelSize = setting.sidePanelSize;
    if (ls.hiddenIdAndPlan !== setting.hiddenIdAndPlan) {
      reStyle('hiddenIdAndPlan', setting.hiddenIdAndPlan);
    }
    ls.sidePanelSizeNum = setting.sidePanelSizeNum;
    ls.hiddenIdAndPlan = setting.hiddenIdAndPlan;
    ls.notifyNewChannel = setting.notifyNewChannel;
    ls.notifyNewChannelTarget = setting.notifyNewChannelTarget;

    // テレビ
    ls.closeSidePanel = setting.closeSidePanel;
    if (ls.sidePanelCloseButton !== setting.sidePanelCloseButton) {
      reStyle('sidePanelCloseButton', setting.sidePanelCloseButton);
    }
    ls.sidePanelCloseButton = setting.sidePanelCloseButton;
    if (ls.showProgramDetail !== setting.showProgramDetail) {
      reStyle('showProgramDetail', setting.showProgramDetail);
    }
    ls.showProgramDetail = setting.showProgramDetail;
    ls.hiddenButtonText = setting.hiddenButtonText;
    if (ls.viewCounter && !setting.viewCounter) resetStatsPlaceholder(false);
    ls.viewCounter = setting.viewCounter;
    if (ls.programInfo2 && !setting.programInfo2) {
      const info2 = document.getElementById(`${sid}_ProgramInfo2`);
      if (info2 && getComputedStyle(info2).display !== 'none') {
        clearTimeout(interval.programInfo2);
        info2.classList.add(`${sid}_ProgramInfo_hidden`);
      }
    }
    if ((setting.programInfo1 || setting.programInfo2) && data.programId) {
      if (data.program.slot?.id !== data.programId) {
        getProgramInfo(data.programId);
      }
    }
    ls.programInfo1 = setting.programInfo1;
    ls.programInfo2 = setting.programInfo2;
    ls.programInfo2Num = setting.programInfo2Num;
    if (ls.nextPrograms !== setting.nextPrograms) {
      reStyle('nextPrograms', setting.nextPrograms);
      showNextPrograms();
    }
    ls.nextPrograms = setting.nextPrograms;
    if (ls.nextProgramsNum !== setting.nextProgramsNum) {
      reStyle('nextPrograms', setting.nextPrograms);
      getNextPrograms();
    }
    ls.nextProgramsNum = setting.nextProgramsNum;
    ls.searchProgram = setting.searchProgram;

    // ビデオ・見逃し視聴
    ls.nextProgramInfo = setting.nextProgramInfo;
    ls.recommendedSeriesInfo = setting.recommendedSeriesInfo;
    ls.skipVideo = setting.skipVideo;

    // ビデオ・見逃し視聴・ライブイベント
    if (ls.videoPadding !== setting.videoPadding) {
      reStyle('videoPadding', setting.videoPadding);
    }
    ls.videoPadding = setting.videoPadding;
    ls.dblclickScroll = setting.dblclickScroll;

    // コメント
    ls.newCommentOneByOne = setting.newCommentOneByOne;
    ls.scrollNewComment = setting.scrollNewComment;
    if (ls.stopCommentScroll !== setting.stopCommentScroll) {
      changeEventListener(
        setting.stopCommentScroll,
        document.querySelector(selector.commentList)?.parentElement,
        'commentScroll',
      );
    }
    ls.stopCommentScroll = setting.stopCommentScroll;
    if (ls.highlightNewComment !== setting.highlightNewComment) {
      reStyle('highlightNewComment', setting.highlightNewComment);
    }
    ls.highlightNewComment = setting.highlightNewComment;
    if (ls.highlightFirstComment !== setting.highlightFirstComment) {
      reStyle('highlightFirstComment', setting.highlightFirstComment);
    }
    ls.highlightFirstComment = setting.highlightFirstComment;
    if (ls.commentFontSize !== setting.commentFontSize) {
      reStyle('commentFontSize', setting.commentFontSize);
    }
    ls.commentFontSize = setting.commentFontSize;
    if (ls.commentFontSizeNum !== setting.commentFontSizeNum) {
      reStyle('commentFontSize', setting.commentFontSize);
    }
    ls.commentFontSizeNum = setting.commentFontSizeNum;
    if (ls.reduceCommentSpace !== setting.reduceCommentSpace) {
      reStyle('reduceCommentSpace', setting.reduceCommentSpace);
    }
    ls.reduceCommentSpace = setting.reduceCommentSpace;
    if (ls.hiddenCommentList && !setting.hiddenCommentList) {
      removeStyle('hiddenCommentList');
      removeStyle('hiddenCommentList2');
    }
    ls.hiddenCommentList = setting.hiddenCommentList;
    if (
      setting.hiddenCommentList &&
      ls.hiddenCommentListNum !== setting.hiddenCommentListNum &&
      document.getElementById(`${sid}_style_hiddenCommentList`)
    ) {
      reStyle('hiddenCommentList', true);
    }
    ls.hiddenCommentListNum = setting.hiddenCommentListNum;
    if (
      setting.hiddenCommentList &&
      ls.hiddenCommentListNum2 !== setting.hiddenCommentListNum2 &&
      document.getElementById(`${sid}_style_hiddenCommentList2`)
    ) {
      reStyle('hiddenCommentList2', setting.hiddenCommentListNum2);
    }
    ls.hiddenCommentListNum2 = setting.hiddenCommentListNum2;
    ls.escKey = setting.escKey;
    ls.enterKey = setting.enterKey;
    ls.reportFormCommentList = setting.reportFormCommentList;

    // 画質
    ls.qualityEnable = setting.qualityEnable;
    if (setting.targetQuality !== ls.targetQuality) {
      changeTargetQuality(setting.targetQuality);
    }
    ls.targetQuality = setting.targetQuality;

    // NGワード
    ls.ngIdEnable = setting.ngIdEnable;
    lsWord.ngWord = setting.ngWord;
    ls.ngWordEnable = setting.ngWordEnable;
    ls.ngConsole = setting.ngConsole;

    // NGワード(警告リストの整合性チェック)
    checkWarningRe();

    // NGコメント
    ls.ngIdEnable = setting.ngIdEnable;
    lsId.ngId = setting.ngId ? [...setting.ngId] : [];
    data.ngId = new Set(setting.ngId);
    if (
      ls.ngIdMaxSize < setting.ngIdMaxSize &&
      setting.ngId.length > setting._ngid[ls.ngIdMaxSize]
    ) {
      setting.ngId.splice(
        0,
        setting.ngId.length - setting._ngid[ls.ngIdMaxSize],
      );
      data.ngId = new Set(setting.ngId);
    }
    ls.ngIdMaxSize = setting.ngIdMaxSize;

    saveStorage();
  };

  /**
   * ローカルストレージに保存する
   */
  const saveStorage = () => {
    try {
      localStorage.setItem(sid, JSON.stringify(ls));
      localStorage.setItem(`${sid}-Word`, JSON.stringify(lsWord));
      localStorage.setItem(`${sid}-Id`, JSON.stringify(lsId));
    } catch (e) {
      log(`${sid}: Failed to save settings to localStorage.`, e, 'error');
    }
  };

  /**
   * 番組を検索する
   * @param {string} q 検索クエリ
   * @param {boolean} keepHistory 検索履歴を維持するか
   */
  const searchProgram = async (q, keepHistory = false) => {
    if (!data.dataProviderRunning || !q || !db) return;

    try {
      if (!keepHistory) {
        data.searchHistory.length = 0;
      }

      // タイムスタンプのスケール調整(ミリ秒 vs 秒)と最新日時の取得
      const { scale, maxTimeMs } = await getDbInfo();

      // 構造としての意味を持つ記号(引用符)を半角に統一
      const normalizedQ = q.replace(/[“”"]/g, '"');

      // クエリを分析して OR 単位で分割する (引用符内は無視)
      const subQueries = ((str) => {
        const res = [];
        let current = '';
        let inQuote = false;
        for (let i = 0; i < str.length; i++) {
          const char = str[i];
          if (char === '"') inQuote = !inQuote;
          if (!inQuote) {
            if (char === '|' || char === '|') {
              res.push(current);
              current = '';
              continue;
            }
            const slice4 = str.slice(i, i + 4).toUpperCase();
            if (slice4 === ' OR ' || slice4 === ' OR ') {
              res.push(current);
              current = '';
              i += 3;
              continue;
            }
          }
          current += char;
        }
        res.push(current);
        return res;
      })(normalizedQ);
      const resultMap = new Map();

      const specialKeys = [
        'binge',
        'first',
        'future',
        'free',
        'last',
        'live',
        'new',
        'next',
        'now',
        'off',
        'past',
        'pick',
        'premium',
      ];

      const nowMs = Date.now();
      const nowDb = nowMs * scale; // DB比較用現在時刻

      // 現在放送中のチャンネルIDを特定する
      const onlineChannelIds = new Set();
      // クエリ全体で 'off' が使われているかチェック(否定の -off も含む)
      if (/(^|[  \-|-])[Oo][Ff][Ff]\b/.test(normalizedQ)) {
        try {
          await db.timetableSlots
            .where('endAt')
            .above(nowDb)
            .each((s) => {
              if (s.startAt <= nowDb) {
                onlineChannelIds.add(s.channelId);
              }
            });
        } catch (e) {
          log('searchProgram(off-check): 失敗', e, 'error');
        }
      }

      await Promise.all(
        subQueries.map(async (subQuery) => {
          try {
            const queryStr = subQuery.trim();
            if (!queryStr) return;

            // 引用符または角括弧で囲まれている場合を考慮してパーツに分割
            const parts =
              queryStr.match(/"[^"]*"|[--]?[[[][^\]]]*[\]]]|\S+/g) || [];
            const flags = Object.fromEntries(
              specialKeys.map((k) => [k, false]),
            );
            const negFlags = Object.fromEntries(
              specialKeys.map((k) => [k, false]),
            );

            const channelFilters = [];
            let dateRange = null; // {start, end}
            const includeKeywords = [];
            const excludeKeywords = [];
            const excludeChannelFilters = [];
            const excludeDateRanges = [];

            // クエリ解析
            for (const part of parts) {
              // 引用符で囲まれている場合は、特殊キーワード等の判定をバイパスして includeKeywords へ追加
              if (
                part.startsWith('"') &&
                part.endsWith('"') &&
                part.length >= 2
              ) {
                const val = part.slice(1, -1);
                if (val) includeKeywords.push(val);
                continue;
              }

              // 除外指定(-始まり)かつ引用符で囲まれている場合: -"keyword"
              if (
                part.startsWith('-"') &&
                part.endsWith('"') &&
                part.length >= 4
              ) {
                const val = part.slice(2, -1);
                if (val) excludeKeywords.push(val);
                continue;
              }

              // 通常の除外指定(-始まり)の確認
              let isNegative = false;
              let workingPart = part;
              if (workingPart.startsWith('-') || workingPart.startsWith('-')) {
                if (workingPart.length > 1) {
                  isNegative = true;
                  workingPart = workingPart.slice(1);
                }
              }

              // 構造判定用の正規化(小文字化+英数字・記号の半角化)
              const normPart = workingPart
                .toLowerCase()
                .replace(/[A-Za-z0-9[]]/g, (s) =>
                  String.fromCharCode(s.charCodeAt(0) - 0xfee0),
                );

              // 1. 特殊キーワードの判定
              if (specialKeys.includes(normPart)) {
                if (isNegative) negFlags[normPart] = true;
                else flags[normPart] = true;
                continue;
              }

              // 2. チャンネルフィルター [keyword] / -[keyword]
              if (normPart.startsWith('[') && normPart.endsWith(']')) {
                const val = normPart.slice(1, -1);
                if (val) {
                  if (isNegative) excludeChannelFilters.push(val);
                  else channelFilters.push(val);
                }
                continue;
              }

              // 3. 日付範囲指定 Key1-Key2 / -Key1-Key2
              if (normPart.includes('-')) {
                const [nk1, nk2] = normPart.split('-');
                let r1 = parseDateKey(nk1);
                if (r1) {
                  // 計算された開始日がDBの最新データより1日以上未来なら、昨年とみなす (4, 6桁時のみ)
                  if (
                    (nk1.length === 4 || nk1.length === 6) &&
                    r1.start > maxTimeMs + 86400000
                  ) {
                    const rPrev = parseDateKey(
                      nk1,
                      new Date(r1.start).getFullYear() - 1,
                    );
                    if (rPrev) r1 = rPrev;
                  }

                  const startYear = new Date(r1.start).getFullYear();
                  const startMonth = new Date(r1.start).getMonth() + 1;
                  let r2;
                  // k2に年が含まれない場合、開始日との前後関係から年を判定する
                  if (nk2.length === 4 || nk2.length === 6) {
                    const m2 = parseInt(nk2.slice(0, 2));
                    const year2 = m2 < startMonth ? startYear + 1 : startYear;
                    r2 = parseDateKey(nk2, year2);
                  } else {
                    r2 = parseDateKey(nk2);
                  }
                  if (r2) {
                    const dr = { start: r1.start, end: r2.end };
                    if (isNegative) excludeDateRanges.push(dr);
                    else dateRange = dr;
                    continue;
                  }
                }
              }

              // 3.5. 相対日時指定
              const relPastMatch = normPart.match(/^(\d+)([dh])[~~〜]$/);
              const relFutureMatch = normPart.match(/^[~~〜](\d+)([dh])$/);
              const m = relPastMatch || relFutureMatch;

              if (m) {
                const val = parseInt(m[1], 10);
                const unit = m[2];
                // h=1時間, d=24時間
                const MS_PER_HOUR = 1000 * 60 * 60;
                const duration =
                  val * (unit === 'd' ? MS_PER_HOUR * 24 : MS_PER_HOUR);

                // 基準は現在時刻 (nowMs)
                // 過去指定 (1h~, 1d~): [Now - Duration, Now]
                // 未来指定 (~1h, ~1d): [Now, Now + Duration]
                let start, end;
                if (relPastMatch) {
                  end = nowMs;
                  start = nowMs - duration;
                } else {
                  start = nowMs;
                  end = nowMs + duration;
                }

                const dr = { start, end };
                if (isNegative) excludeDateRanges.push(dr);
                else dateRange = dr;
                continue;
              }

              // 4. 日付単体指定
              let r = parseDateKey(normPart);
              if (r) {
                // 同様に、未来すぎる場合は昨年とみなす
                if (
                  (normPart.length === 4 || normPart.length === 6) &&
                  r.start > maxTimeMs + 86400000
                ) {
                  const rPrev = parseDateKey(
                    normPart,
                    new Date(r.start).getFullYear() - 1,
                  );
                  if (rPrev) r = rPrev;
                }
                if (isNegative) excludeDateRanges.push(r);
                else dateRange = r;
                continue;
              }

              // 5. 通常キーワード(判定にヒットしなかった場合は、元の文字種のまま追加)
              if (isNegative) {
                excludeKeywords.push(workingPart);
              } else {
                includeKeywords.push(workingPart);
              }
            }

            // Dexieコレクションの構築
            /** @type {any} */
            let collection = db.timetableSlots.toCollection();

            if (flags.now) {
              // 現在放送中: endAt > now
              // startAt <= now のフィルタはメモリ上で行う
              collection = db.timetableSlots.where('endAt').above(nowDb);
            } else if (flags.past) {
              // 過去の番組: endAt <= now
              collection = db.timetableSlots.where('endAt').belowOrEqual(nowDb);
            } else if (flags.future || flags.next) {
              // 未来の番組: startAt > now
              collection = db.timetableSlots.where('startAt').above(nowDb);
            } else if (dateRange) {
              // 日付範囲指定あり
              const s = Math.floor(dateRange.start * scale);
              const e = Math.floor(dateRange.end * scale);
              collection = db.timetableSlots
                .where('startAt')
                .between(s, e, true, true);
            }

            let results = await collection.toArray();

            // メモリフィルタリング
            // 1. 基本的な時間軸フィルタ (now / next / past / future)
            if (flags.now) {
              results = results.filter(
                (/** @type {any} */ p) => p.startAt <= nowDb,
              );
            } else if (flags.next) {
              const nextMap = new Map();
              results.forEach((/** @type {any} */ p) => {
                if (
                  !nextMap.has(p.channelId) ||
                  p.startAt < nextMap.get(p.channelId).startAt
                ) {
                  nextMap.set(p.channelId, p);
                }
              });
              results = Array.from(nextMap.values());
            }

            // 2. 特殊キーワードの包含/除外フィルタ
            const getIsFree = (/** @type {TimetableSlot} */ p) => {
              const endAt = normalizeTimestamp(p.timeshiftFreeEndAt || 0);
              return (
                !!p.timeshiftFreeEndAt &&
                (normalizeTimestamp(p.startAt) > nowMs ? true : endAt > nowMs)
              );
            };
            const getIsPremium = (/** @type {TimetableSlot} */ p) => {
              const endAt = normalizeTimestamp(p.timeshiftEndAt || 0);
              return (
                !!p.timeshiftEndAt &&
                (normalizeTimestamp(p.startAt) > nowMs ? true : endAt > nowMs)
              );
            };
            const getIsNow = (/** @type {TimetableSlot} */ p) =>
              p.startAt <= nowDb && p.endAt > nowDb;

            const conditionMap = {
              binge: (/** @type {TimetableSlot} */ p) =>
                !!p.mark?.bingeWatching,
              first: (/** @type {TimetableSlot} */ p) =>
                !!(p.mark?.first || p.labels?.includes('first')),
              free: getIsFree,
              future: (/** @type {TimetableSlot} */ p) => p.startAt > nowDb,
              last: (/** @type {TimetableSlot} */ p) =>
                !!(p.mark?.last || p.labels?.includes('last')),
              live: (/** @type {TimetableSlot} */ p) => !!p.mark?.live,
              new: (/** @type {TimetableSlot} */ p) => !!p.mark?.newcomer,
              now: getIsNow,
              off: (/** @type {TimetableSlot} */ p) =>
                !onlineChannelIds.has(p.channelId),
              past: (/** @type {TimetableSlot} */ p) => p.endAt <= nowDb,
              pick: (/** @type {TimetableSlot} */ p) =>
                !!p.mark?.recommendation,
              premium: getIsPremium,
            };

            specialKeys.forEach((key) => {
              if (flags[key] && conditionMap[key]) {
                results = results.filter(conditionMap[key]);
              }
              if (negFlags[key] && conditionMap[key]) {
                results = results.filter(
                  (p) => !conditionMap[key](/** @type {TimetableSlot} */ (p)),
                );
              }
            });

            const createChannelMatcher = (/** @type {TimetableSlot} */ p) => {
              const cid = p.channelId?.toLowerCase() || '';
              const cname = data.channels[p.channelId]?.toLowerCase() || '';
              return (/** @type {string} */ target) => {
                const t = target.toLowerCase();
                return cid.includes(t) || cname.includes(t);
              };
            };

            // チャンネルフィルタ (Include)
            if (channelFilters.length > 0) {
              results = results.filter((/** @type {TimetableSlot} */ p) =>
                channelFilters.every(createChannelMatcher(p)),
              );
            }

            // チャンネルフィルタ (Exclude)
            if (excludeChannelFilters.length > 0) {
              results = results.filter(
                (/** @type {TimetableSlot} */ p) =>
                  !excludeChannelFilters.some(createChannelMatcher(p)),
              );
            }

            // 日付フィルタ (Exclude)
            if (excludeDateRanges.length > 0) {
              results = results.filter((/** @type {any} */ p) => {
                const start = normalizeTimestamp(p.startAt);
                const end = normalizeTimestamp(p.endAt);
                return !excludeDateRanges.some((dr) => {
                  // 番組の時間が除外範囲と重なっている場合は除外
                  return start < dr.end && end > dr.start;
                });
              });
            }

            // キーワードフィルタ (Include)
            if (includeKeywords.length > 0) {
              results = results.filter((/** @type {any} */ p) => {
                const title = p.title?.toLowerCase() || '';
                const highlight = p.highlight?.toLowerCase() || '';
                return includeKeywords.every((kw) => {
                  const target = kw.toLowerCase();
                  return title.includes(target) || highlight.includes(target);
                });
              });
            }

            // キーワードフィルタ (Exclude)
            if (excludeKeywords.length > 0) {
              results = results.filter((/** @type {any} */ p) => {
                const title = p.title?.toLowerCase() || '';
                const highlight = p.highlight?.toLowerCase() || '';
                return !excludeKeywords.some(
                  (ex) =>
                    title.includes(ex.toLowerCase()) ||
                    highlight.includes(ex.toLowerCase()),
                );
              });
            }

            results.forEach((/** @type {any} */ p) => {
              if (!resultMap.has(p.id)) resultMap.set(p.id, p);
            });
          } catch (e) {
            log('searchProgram(subQuery): 失敗', e, 'error');
          }
        }),
      );

      const results = Array.from(resultMap.values());
      results.sort((a, b) => a.startAt - b.startAt);
      showSearchResults(results, q);
    } catch (e) {
      log('searchProgram: 失敗', e, 'error');
    }
  };

  /**
   * 見逃し視聴で動画をシークしたとき
   */
  const seekedVideo = async () => {
    log('seekedVideo');
    data.archiveComments.length = 0;
    data.comment.length = 0;
    data.commentId.clear();
    await sleep(1000);
    closeCommentReportForm();
  };

  /**
   * 見逃しコメントを登録する
   * @param {number} n
   * @returns
   */
  const setArchiveComments = (n) => {
    /** @type {HTMLCollection|null} */
    const acc = document.getElementsByClassName(
      selector.archiveCommentContainer,
    );
    if (acc.length && acc[0] instanceof HTMLDivElement) {
      const ac = getCommentProps(acc[0], 'comments', 'ts');
      if (ac instanceof Array) {
        data.archiveComments = ac.slice(n);
      } else data.archiveComments.length = 0;
      return true;
    }
    data.archiveComments.length = 0;
    return false;
  };

  /**
   * このスクリプトを初めて使うときやローカルストレージを削除したとき初期値を登録する
   */
  const setInitialValue = () => {
    /**
     * 変数aの型がsとは異なる場合trueを返す
     * @param {string} t 型
     * @param {any} a 判別したい変数
     * @returns {boolean}
     */
    const notType = (t, a) =>
      Object.prototype.toString.call(a).slice(8, -1) !== t ? true : false;
    /**
     * 設定用の変数が異なる型の場合は初期値を登録する
     * @param {string} s 変数名
     * @param {string} t 型の先頭3文字
     * @param {any} a 初期値
     */
    const setValue = (s, t, a) => {
      if (notType(t, setting[s])) {
        setting[s] = a;
        if (s === 'ngWord') lsWord[s] = '';
        else if (s === 'ngId') lsId[s] = [];
        else setting[s] = a;
      }
    };
    if (!lsWord.ngWord) lsWord.ngWord = '';
    if (!Array.isArray(lsWord.warningRe)) lsWord.warningRe = [];
    if (!Array.isArray(lsId.ngId)) lsId.ngId = [];

    // 全般
    setValue('reduceNavigation', 'Boolean', true);
    setValue('mouseoverNavigation', 'Boolean', true);
    setValue('closeNotification', 'Boolean', false);
    setValue('videoResolution', 'Boolean', true);
    setValue('headerPosition', 'Boolean', true);
    setValue('semiTransparent', 'Boolean', true);
    setValue('smallFontSize', 'Boolean', true);
    setValue('overlapSidePanel', 'Boolean', true);
    setValue('sidePanelBackground', 'Boolean', true);
    setValue('sidePanelSize', 'Boolean', false);
    setValue('sidePanelSizeNum', 'String', '320');
    setValue('hiddenIdAndPlan', 'Boolean', true);
    setValue('notifyNewChannel', 'Boolean', true);
    setValue('notifyNewChannelTarget', 'Number', 0);
    // テレビ
    setValue('closeSidePanel', 'Boolean', true);
    setValue('sidePanelCloseButton', 'Boolean', true);
    setValue('showProgramDetail', 'Boolean', true);
    setValue('hiddenButtonText', 'Boolean', true);
    setValue('viewCounter', 'Boolean', false);
    setValue('programInfo1', 'Boolean', true);
    setValue('programInfo2', 'Boolean', true);
    setValue('programInfo2Num', 'String', '3');
    setValue('nextPrograms', 'Boolean', true);
    setValue('nextProgramsNum', 'String', '1');
    setValue('searchProgram', 'Boolean', true);
    // ビデオ・見逃し視聴
    setValue('nextProgramInfo', 'Boolean', true);
    setValue('recommendedSeriesInfo', 'Boolean', true);
    setValue('skipVideo', 'Boolean', false);
    // ビデオ・見逃し視聴・ライブイベント
    setValue('videoPadding', 'Boolean', true);
    setValue('dblclickScroll', 'Boolean', true);
    // コメント
    setValue('newCommentOneByOne', 'Boolean', true);
    setValue('scrollNewComment', 'Boolean', true);
    setValue('stopCommentScroll', 'Boolean', true);
    setValue('highlightNewComment', 'Boolean', true);
    setValue('highlightFirstComment', 'Boolean', true);
    setValue('commentFontSize', 'Boolean', false);
    setValue('commentFontSizeNum', 'String', '13');
    setValue('reduceCommentSpace', 'Boolean', true);
    setValue('hiddenCommentList', 'Boolean', true);
    setValue('hiddenCommentListNum', 'String', '50');
    setValue('hiddenCommentListNum2', 'String', '');
    setValue('escKey', 'Boolean', true);
    setValue('enterKey', 'Boolean', true);
    setValue('reportFormCommentList', 'Boolean', true);
    // 画質
    setValue('qualityEnable', 'Boolean', true);
    setValue('targetQuality', 'Number', 0);
    // NGワード
    setValue('ngWordEnable', 'Boolean', true);
    setValue('ngWord', 'String', '');
    setValue('ngConsole', 'Boolean', false);
    // NG ID
    setValue('ngId', 'Array', '');
    setValue('ngIdEnable', 'Boolean', true);
    setValue('ngIdMaxSize', 'Number', 0);
    saveStorage();
  };

  /**
   * デスクトップ通知を表示する
   * @param {{detail: {id: string, hasProgram: boolean, program?: {id: string, title: string, startAt: number, endAt: number, tId?: string, tName?: string, tVersion?: string}}, headerText: string}} item
   */
  const showDesktopNotification = (item) => {
    const { detail, headerText } = item;
    let body = '';
    const channelName = data.channels[detail.id] || detail.id;

    if (detail.hasProgram && detail.program) {
      const title = detail.program.title;
      const time = `${returnFormatDate(
        detail.program.startAt,
        'date',
      )} ${returnFormatDate(
        detail.program.startAt,
        'time',
      )}~${returnFormatDate(detail.program.endAt, 'time')}`;
      body = `${channelName}\n${title}\n${time}`;
    } else {
      body = `${channelName}\n放送予定なし`;
    }

    const iconImage =
      detail.hasProgram &&
      detail.program?.tId &&
      detail.program?.tName &&
      detail.program?.tVersion
        ? `${data.imageDomain}image/programs/${detail.program.tId}/${detail.program.tName}.png?background=000000&fit=fill&height=180&quality=75&version=${detail.program.tVersion}&width=180`
        : `${data.imageDomain}image/channels/${detail.id}/logo.png?background=000000&fit=fill&height=180&width=180`;

    if ('Notification' in window) {
      const createNotification = () =>
        new Notification(headerText, { body, icon: iconImage });
      if (Notification.permission === 'granted') {
        createNotification();
      } else if (Notification.permission !== 'denied') {
        Notification.requestPermission().then((permission) => {
          if (permission === 'granted') {
            createNotification();
          }
        });
      }
    }
  };

  /**
   * 新しいチャンネルを通知する
   * @param {{id: string, hasProgram: boolean, program?: {id: string, title: string, startAt: number, endAt: number, tId?: string, tName?: string, tVersion?: string}}[]} details
   * @param {'discovery'|'before'|'started'} type 通知の種類
   */
  const showNewChannelNotification = (details, type) => {
    log('showNewChannelNotification', details, type);
    const headerTextMap = {
      discovery: '新しいチャンネルを見つけました',
      before: '新しいチャンネルで放送が始まります',
      started: '新しいチャンネルで放送が始まりました',
    };
    const headerText = headerTextMap[type] || 'チャンネルが追加されました';

    if (setting.notifyNewChannelTarget === 1) {
      // デスクトップ通知(1件ずつバラして表示する)
      details.forEach((detail) => {
        showDesktopNotification({ detail, headerText });
      });
    } else {
      // ページ内通知
      notificationQueue.push({ details, headerText });
      processNotificationQueue();
    }
  };

  /**
   * テレビのチャンネルリストに次以降の番組情報を追加する
   */
  const showNextPrograms = () => {
    const isShrunk = document.querySelector(selector.tvChannelListShrunk);
    if (!setting.nextPrograms || !data.dataProviderRunning || isShrunk) {
      document
        .querySelectorAll(`.${sid}_NextProgramItem__details`)
        .forEach((el) => el.remove());
      return;
    }
    if (!data.nextPrograms || data.nextPrograms.length === 0) return;

    /** @type {NodeListOf<HTMLElement>} */
    const channelItems = document.querySelectorAll(selector.tvChannelListItem);
    if (!channelItems.length) return;

    const num = parseInt(/** @type {string} */ (setting.nextProgramsNum)) || 1;

    channelItems.forEach((item) => {
      const innerEl = item.querySelector(selector.tvChannelListItemInner);
      if (!innerEl) return;

      // チャンネルIDを取得
      const href =
        item.getAttribute('href') ||
        item.querySelector('a[href]')?.getAttribute('href');
      if (!href) return;
      const channelId = href.split('/').pop()?.split('?')[0];
      if (!channelId) return;

      // データの取得
      const channelData = data.nextPrograms.find(
        (p) => p && p.channelId === channelId,
      );

      const programs = channelData ? channelData.programs : [];

      // 設定された数だけ要素をチェック・作成
      for (let i = 0; i < num; i++) {
        const nextProgram = programs[i];
        const nextId = `${sid}_NextProgramItem__details_${i}`;
        let infoEl = item.querySelector(`.${nextId}`);

        // 要素がない場合は作成
        if (!infoEl) {
          infoEl = document.createElement('div');
          infoEl.className = `${sid}_NextProgramItem__details ${nextId}`;
          infoEl.innerHTML = `
            <p class="${sid}_NextProgramItem__title">
              <span class="${sid}_NextProgramItem__title-text"></span>
            </p>
            <p class="${sid}_NextProgramItem__broadcasting-date"></p>
          `;
          innerEl.appendChild(infoEl);
        }

        const titleTextEl = infoEl.querySelector(
          `.${sid}_NextProgramItem__title-text`,
        );
        const timeEl = infoEl.querySelector(
          `.${sid}_NextProgramItem__broadcasting-date`,
        );

        let nextTitle = '';
        let nextTime = '';
        let nextTimeFull = '';

        if (nextProgram) {
          const start = nextProgram.startAt * 1000;
          const end = nextProgram.endAt * 1000;

          nextTitle = nextProgram.title;
          nextTime = `${returnFormatDate(start, 'time')}〜${returnFormatDate(
            end,
            'time',
          )}`;
          nextTimeFull = `${returnFormatDate(start)} 〜 ${returnFormatDate(
            end,
          )}`;
        }

        // 内容の更新
        const fullTooltip = nextProgram ? `${nextTitle}\n${nextTimeFull}` : '';
        if (infoEl instanceof HTMLDivElement && infoEl.title !== fullTooltip) {
          infoEl.title = fullTooltip;
        }
        if (
          titleTextEl instanceof HTMLSpanElement &&
          titleTextEl.textContent !== nextTitle
        ) {
          titleTextEl.textContent = nextTitle;
        }
        if (
          timeEl instanceof HTMLParagraphElement &&
          timeEl.textContent !== nextTime
        ) {
          timeEl.textContent = nextTime;
        }
      }

      // 設定数を超えて存在する古い要素を削除
      item
        .querySelectorAll(`.${sid}_NextProgramItem__details`)
        .forEach((el) => {
          const match = el.className.match(
            new RegExp(`${sid}_NextProgramItem__details_(\\d+)`),
          );
          if (match && parseInt(match[1]) >= num) {
            el.remove();
          }
        });
    });
  };

  /**
   * 検索結果を表示する
   * @param {Array<Object>} results 検索結果
   * @param {string} q 検索クエリ
   */
  const showSearchResults = (results, q) => {
    let container = document.getElementById(`${sid}_SearchResults`);
    if (!container) {
      container = document.createElement('div');
      container.id = `${sid}_SearchResults`;
      const screen =
        document.querySelector(selector.tvContainerScreen) || document.body;
      if (screen) {
        screen.appendChild(container);
      }
    }

    const hasBackHistory = data.searchHistory.length > 0;
    const channelMatch = q.match(/^\[(.+)\]$/);
    let displayQ = q;
    let channelIdForLink = null;

    if (channelMatch) {
      const idOrName = channelMatch[1];
      if (data.channels[idOrName]) {
        // IDが一致した場合は名前に置き換え
        displayQ = `[${data.channels[idOrName]}]`;
        channelIdForLink = idOrName;
      } else {
        // 名前で検索されている可能性があるので逆引き
        const entry = Object.entries(data.channels).find(
          ([, name]) => name === idOrName,
        );
        if (entry) {
          channelIdForLink = entry[0];
        }
      }
    }

    let titleHtml = `検索結果: ${escapeHTML(displayQ)} (${results.length}件)`;

    if (hasBackHistory && channelMatch && channelIdForLink) {
      titleHtml = `検索結果: <a href="https://abema.tv/timetable/channels/${encodeURIComponent(
        channelIdForLink,
      )}" target="_blank">${escapeHTML(displayQ)}</a> (${results.length}件)`;
    }

    let listHtml = '';
    if (results.length === 0) {
      listHtml = `<div class="${sid}_SearchResults-noitem">該当する番組は見つかりませんでした。</div>`;
    } else {
      const now = new Date();
      const today = new Date(
        now.getFullYear(),
        now.getMonth(),
        now.getDate(),
      ).getTime();
      const oneDay = 86400000;
      const uniqueChannels = new Set(results.map((p) => p.channelId));
      const isSingleChannel = hasBackHistory && uniqueChannels.size === 1;

      results.forEach((p) => {
        // 時刻をミリ秒に正規化
        const pStartMs = normalizeTimestamp(p.startAt);
        const pDate = new Date(pStartMs);
        const pDayStartTime = new Date(
          pDate.getFullYear(),
          pDate.getMonth(),
          pDate.getDate(),
        ).getTime();

        const diff = Math.round((pDayStartTime - today) / oneDay);
        let dateClass;
        if (diff === 0) dateClass = 'is-today';
        else if (diff > 0) {
          dateClass = `is-next-${Math.min(diff, 7)}`;
        } else {
          dateClass = `is-pre-${Math.min(Math.abs(diff), 7)}`;
        }

        let label = '';
        if (p.mark?.newcomer) {
          label += `<span class="${sid}_ProgramInfo-label-new">新</span>`;
        }
        if (p.mark?.bingeWatching) {
          label += `<span class="${sid}_ProgramInfo-label-binge">一挙</span>`;
        }
        if (p.mark?.recommendation) {
          label += `<span class="${sid}_ProgramInfo-label-pick">注目</span>`;
        }
        if (p.mark?.live) {
          label += `<span class="${sid}_ProgramInfo-label-live">生</span>`;
        }
        if (p.mark?.first) {
          label += `<span class="${sid}_ProgramInfo-label-first">初</span>`;
        }

        let timeStr = `${returnFormatDate(p.startAt)} ~ ${returnFormatDate(
          p.endAt,
          'time',
        )}`;

        // 見逃し視聴情報のバッジ生成
        const tsTitles = [];
        const nowMs = Date.now();
        let badgeClass = '';
        let badgeText = '';

        if (p.timeshiftFreeEndAt) {
          if (normalizeTimestamp(p.timeshiftFreeEndAt) > nowMs) {
            tsTitles.push(
              `無料見逃し視聴:${returnFormatDate(p.timeshiftFreeEndAt)}まで`,
            );
            badgeClass = `${sid}_SearchResults-item-ts-free`;
            badgeText = '無料';
          }
        }
        if (p.timeshiftEndAt) {
          if (normalizeTimestamp(p.timeshiftEndAt) > nowMs) {
            tsTitles.push(
              `ABEMAプレミアム見逃し視聴:${returnFormatDate(
                p.timeshiftEndAt,
              )}まで`,
            );
            if (!badgeText) {
              badgeClass = `${sid}_SearchResults-item-ts-prem`;
              badgeText = '見逃し';
            }
          }
        }
        if (badgeText) {
          timeStr += `<span class="${badgeClass}" title="${tsTitles.join(
            '\n',
          )}">${badgeText}</span>`;
        }

        const itemClass =
          normalizeTimestamp(p.endAt) < Date.now() ? 'is-past' : '';

        const channelName = data.channels[p.channelId] || p.channelId;
        const channelQuery = `[${p.channelId}]`;
        const channelDisplay = isSingleChannel
          ? ''
          : `
    <span class="${sid}_SearchResults-item-channel">
      <a href="#" class="${sid}_SearchResults-channel-link" data-search-q="${escapeHTML(
        channelQuery,
      )}">${escapeHTML(channelName)}</a>
    </span>`;

        listHtml += `
<div class="${sid}_SearchResults-item ${itemClass} ${dateClass}">
  <div class="${sid}_SearchResults-item-sidebar"></div>
  <div class="${sid}_SearchResults-item-header">
    <span class="${sid}_SearchResults-item-time">${timeStr}</span>${channelDisplay}
  </div>
  <div class="${sid}_SearchResults-item-title">
    <a href="https://abema.tv/channels/${encodeURIComponent(p.channelId)}/slots/${encodeURIComponent(
      p.id,
    )}" target="_blank" class="${sid}_SearchResults-item-link" data-program-id="${escapeHTML(
      p.id,
    )}">${label}${escapeHTML(p.title)}${
      p.mark?.last
        ? `<span class="${sid}_ProgramInfo-label-last">終</span>`
        : ''
    }</a>
  </div>
  <div class="${sid}_SearchResults-item-highlight">${
    escapeHTML(p.highlight) || ''
  }</div>
</div>
        `;
      });
    }

    const html = `
<div class="${sid}_SearchResults-header">
  <span class="${sid}_SearchResults-title">${titleHtml}</span>
  <div class="${sid}_SearchResults-header-buttons">
    ${
      hasBackHistory
        ? `<button id="${sid}_SearchResults-back" title="前の検索結果に戻る">戻る</button>`
        : ''
    }
    <button id="${sid}_SearchResults-detail-back" title="検索結果に戻る">戻る</button>
    <button id="${sid}_SearchResults-help-btn" title="使い方を表示">?</button>
    <button id="${sid}_SearchResults-close" title="閉じる">×</button>
  </div>
</div>
<div id="${sid}_SearchResults-help" class="${sid}_SearchResults-help-content">
  <p>テレビ番組の検索結果をページ遷移せずに表示します。<br>Shiftキーを押しながら検索すると通常の検索結果ページを開きます。</p>
  <p>スペースで区切ると、すべての条件に一致する番組を検索します(AND検索)。<br><code>OR</code> または <code>|</code> で区切ると、いずれかの条件に一致する番組を検索します(OR検索)。<br><code>"</code> 引用符 <code>"</code> で囲むと、特殊キーワードなどを無視して検索します(フレーズ検索)。</p>
  <p>検索結果のチャンネル名をクリックすると、そのチャンネルの番組を一覧表示します。</p>
  <p><b>特殊キーワード</b></p>
  <p>番組の状態や属性で絞り込みます。<br>頭に <code>-</code>(ハイフン)を付けると、その条件を除外できます(nextを除く)。</p>
  <ul>
    <li><code>now</code>: 現在放送中の番組</li>
    <li><code>next</code>: 次に放送される番組(各チャンネル1件)</li>
    <li><code>past</code>: 放送が終了した番組</li>
    <li><code>future</code>: 今後放送される番組</li>
    <li><code>off</code>: 現在放送休止しているチャンネルの番組</li>
    <li><code>free</code>: 無料の見逃し視聴対象</li>
    <li><code>premium</code>: プレミアム限定の見逃し視聴対象</li>
    <li><code>new</code>: 新着</li>
    <li><code>live</code>: 生放送</li>
    <li><code>binge</code>: 一挙放送</li>
    <li><code>pick</code>: 注目</li>
    <li><code>first</code>: 初回</li>
    <li><code>last</code>: 最終回</li>
  </ul>
  <p><b>日付・時間の指定</b></p>
  <p>数字の桁数によって、日付や時間を限定して検索できます。<br>ハイフンで繋ぐことで範囲指定もできます。</p>
  <ul>
    <li><code>1231</code>: 12月31日の番組</li>
    <li><code>123122</code>: 12月31日 22時台の番組</li>
    <li><code>20261231</code>: 2026年12月31日の番組</li>
    <li><code>2026123122</code>: 2026年12月31日 22時台の番組</li>
    <li><code>1231-010203</code>: 12月31日から1月2日 3時台までの範囲</li>
    <li><code>12h~</code>: 過去12時間以内(12時間前から現在までの範囲)</li>
    <li><code>~1d</code>: 今後1日以内(現在から24時間後までの範囲)</li>
  </ul>
  <p><b>高度な指定</b></p>
  <ul>
    <li><code>[abema-anime]</code>: チャンネルIDで絞り込み(部分一致)</li>
    <li><code>[ABEMA アニメチャンネル]</code>: チャンネル名で絞り込み(部分一致)</li>
    <li><code>-キーワード</code>: 検索結果からこのキーワードを含む番組を除外</li>
  </ul>
  <p><b>補足事項</b></p>
  <ul>
    <li>番組表に掲載されている番組のみを検索対象とします。</li>
    <li>視聴期限が切れた過去の番組は検索結果に表示されません。</li>
    <li>特殊キーワードなどは全角でも半角でも検索できます。</li>
    <li>番組によっては初回・最終回の情報が設定されていないため、<br><code>first</code>や<code>last</code>では表示されない場合があります。</li>
  </ul>
</div>
<div id="${sid}_SearchResults-list" class="${sid}_SearchResults-list">
  ${listHtml}
</div>
<div id="${sid}_SearchResults-detail" class="${sid}_SearchResults-detail">
  <div class="${sid}_SearchResults-detail-content"></div>
</div>
`;
    container.innerHTML = html;
    container.classList.remove('is-showing-detail');
    container.classList.remove(`${sid}_SearchResults-hidden`);

    const listContainer = document.getElementById(`${sid}_SearchResults-list`);
    const detailContainer = document.getElementById(
      `${sid}_SearchResults-detail`,
    );
    /** @type {HTMLElement|null} */
    const detailContent =
      detailContainer?.querySelector(`.${sid}_SearchResults-detail-content`) ??
      null;

    const helpBtn = document.getElementById(`${sid}_SearchResults-help-btn`);
    const helpContent = document.getElementById(`${sid}_SearchResults-help`);
    const titleElement = container.querySelector(`.${sid}_SearchResults-title`);
    const defaultTitle = titleElement ? titleElement.textContent : '';

    document
      .querySelectorAll(`.${sid}_SearchResults-item-link`)
      .forEach((link) => {
        link.addEventListener('click', async (e) => {
          const me = /** @type {MouseEvent} */ (e);
          if (me.shiftKey || me.ctrlKey || me.metaKey) return;
          e.preventDefault();
          const programId = link.getAttribute('data-program-id');
          if (
            programId &&
            db &&
            detailContainer &&
            detailContent &&
            listContainer
          ) {
            try {
              const slot =
                (await db.broadcastSlots.get(programId)) ||
                (await db.timetableSlots.get(programId));
              if (slot) {
                createProgramInfo(slot, detailContent);
                container.classList.add('is-showing-detail');
                listContainer.classList.add('is-hidden');
                detailContainer.classList.add('is-visible');
                detailContainer.scrollTop = 0;
              }
            } catch (err) {
              log('検索結果の取得に失敗', err, 'error');
            }
          }
        });
      });

    const resetToListView = () => {
      container.classList.remove('is-showing-detail', 'is-showing-help');
      listContainer?.classList.remove('is-hidden');
      detailContainer?.classList.remove('is-visible');
      if (helpContent) helpContent.classList.remove('is-visible');
      if (helpBtn) {
        helpBtn.textContent = '?';
        helpBtn.title = '使い方を表示';
      }
    };

    document
      .getElementById(`${sid}_SearchResults-detail-back`)
      ?.addEventListener('click', () => {
        if (titleElement) titleElement.textContent = defaultTitle;
        resetToListView();
      });

    document
      .getElementById(`${sid}_SearchResults-close`)
      ?.addEventListener('click', () => {
        container.classList.add(`${sid}_SearchResults-hidden`);
        resetToListView();
      });

    document
      .getElementById(`${sid}_SearchResults-back`)
      ?.addEventListener('click', () => {
        const prevQ = data.searchHistory.pop();
        if (prevQ) searchProgram(prevQ, true);
      });

    document
      .querySelectorAll(`.${sid}_SearchResults-channel-link`)
      .forEach((el) => {
        el.addEventListener('click', (e) => {
          e.preventDefault();
          const searchQ = el.getAttribute('data-search-q');
          if (searchQ) {
            // HTMLエスケープをデコードする
            const ta = document.createElement('textarea');
            ta.innerHTML = searchQ;
            data.searchHistory.push(q);
            searchProgram(ta.value, true);
          }
        });
      });

    if (helpBtn && helpContent && listContainer) {
      helpBtn.addEventListener('click', () => {
        const isHelpVisible = helpContent.classList.toggle('is-visible');
        container.classList.toggle('is-showing-help', isHelpVisible);
        if (isHelpVisible) {
          listContainer.classList.add('is-hidden');
          detailContainer?.classList.remove('is-visible');
        } else {
          if (container.classList.contains('is-showing-detail')) {
            detailContainer?.classList.add('is-visible');
          } else {
            listContainer.classList.remove('is-hidden');
          }
        }
        helpBtn.textContent = isHelpVisible ? '戻る' : '?';
        helpBtn.title = isHelpVisible ? '検索結果に戻る' : '使い方を表示';
        if (titleElement) {
          titleElement.textContent = isHelpVisible
            ? '検索の使い方'
            : defaultTitle;
        }
      });
    }
  };

  /**
   * 動画の解像度と表示領域サイズを調べて表示する
   */
  const showVideoResolution = () => {
    if (!setting.videoResolution) return;
    const retry = () => {
      data.showVideoResolution = false;
      showVideoResolution();
    };
    clearTimeout(interval.resolution);
    interval.resolution = setTimeout(() => {
      const dpr = window.devicePixelRatio,
        footer = document.querySelector(selector.footer),
        content = returnContentType(),
        vi = returnVideo(),
        vr = document.getElementById(`${sid}_VideoResolution`),
        ch = vi?.clientHeight,
        cw = vi?.clientWidth,
        vh = vi?.videoHeight,
        vw = vi?.videoWidth;
      if (vi && dpr && ch && cw && vh && vw) {
        if (
          !vr ||
          video.pixelRatio !== dpr ||
          video.clientHeight !== ch ||
          video.clientWidth !== cw ||
          video.videoHeight !== vh ||
          video.videoWidth !== vw ||
          !video.maxHeight
        ) {
          let desc = `動画解像度: ${vw}×${vh}`,
            maxHeight = 0;
          if (/tv|le/.test(content)) {
            const vt = returnVideoTracks();
            if (vt?.qualities) {
              if (vt.qualities[0].height < vt.qualities.at(-1).height) {
                maxHeight = vt.qualities.at(-1).height;
              } else {
                maxHeight = vt.qualities[0].height;
              }
            } else {
              const vc = document.querySelector(selector.videoContainer);
              if (vc instanceof HTMLDivElement) {
                /** @type {Object} */
                const as = getCommentProps(vc, 'AdaptationSet', content);
                if (as instanceof Array) {
                  for (const e of as) {
                    if (/^video\//.test(e.mimeType)) {
                      const availableHeight = e.Representation.map(
                        (r) => r.height,
                      );
                      maxHeight = Math.max(...availableHeight);
                    }
                  }
                } else {
                  if (!data.showVideoResolution) {
                    data.showVideoResolution = true;
                    setTimeout(retry, 1000);
                  }
                }
              }
            }
          }
          if (maxHeight) desc += ` (Max: ${maxHeight}p)`;
          desc += ` / 表示領域: ${cw}×${ch}`;
          if (dpr !== 1) desc += ` * ${dpr}`;
          if (mainElement) observerE.disconnect();
          if (vr) {
            vr.innerText = desc;
          } else {
            const div = document.createElement('div');
            div.id = `${sid}_VideoResolution`;
            div.innerText = desc;
            if (footer) footer.appendChild(div);
          }
          if (mainElement) {
            observerE.observe(mainElement, { childList: true, subtree: true });
          }
          if (maxHeight && !video.maxHeight) {
            changeTargetQuality(setting.targetQuality);
          }
          video.clientHeight = ch;
          video.clientWidth = cw;
          video.maxHeight = maxHeight;
          video.pixelRatio = dpr;
          video.videoHeight = vh;
          video.videoWidth = vw;
        }
      } else {
        if (!data.showVideoResolution) {
          data.showVideoResolution = true;
          setTimeout(retry, 1000);
        }
      }
    }, 100);
  };

  /**
   * 指定時間だけ待機する
   * @param {number} ms
   * @returns
   */
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

  /**
   * ページを開いて動画が表示されたら1度だけ実行
   */
  const startFirstObserve = () => {
    log('startFirstObserve');
    document.addEventListener('keydown', checkKeyDown, true);
    document.addEventListener('click', checkClick);
    document.addEventListener('dblclick', checkDblclick);
    document.addEventListener('contextmenu', checkContextmenu);
    const main = document.querySelector(selector.main),
      head = document.querySelector('head');
    mainElement = main;
    if (main && head) {
      observerE.observe(main, { childList: true, subtree: true });
      observerT.observe(head, { childList: true });
    } else log('startFirstObserve: Not found element.', 'warn');
  };

  /**
   * 視聴中の番組情報が記載されている要素を表示/非表示
   */
  const toggleProgramInfo = () => {
    const e1 = document.getElementById(`${sid}_ProgramInfo1`);
    if (e1) {
      if (e1.classList.contains(`${sid}_ProgramInfo_hidden`)) {
        e1.classList.remove(`${sid}_ProgramInfo_hidden`);
        const e2 = document.getElementById(`${sid}_ProgramInfo2`);
        if (e2 && getComputedStyle(e2).display !== 'none') {
          clearTimeout(interval.programInfo2);
          e2.classList.add(`${sid}_ProgramInfo_hidden`);
        }
      } else {
        e1.classList.add(`${sid}_ProgramInfo_hidden`);
      }
    }
  };

  /**
   * 視聴数を更新する
   * @param {string} [slotId] 番組ID
   * @param {Object} [stats] ステータス情報
   */
  const updateStatsUI = (slotId, stats) => {
    if (!setting.viewCounter || !data.dataProviderRunning) return;
    if (slotId && stats) {
      data.stats = { slotId, view: stats.view, comment: stats.comment };
    }

    const screen = document.querySelector(selector.tvScreen);
    const currentId =
      screen instanceof HTMLElement
        ? getCommentProps(screen, 'programId', 'tv')
        : '';

    if (data.stats.slotId === currentId && data.stats.view) {
      const ta = document.querySelector(selector.commentTextarea);
      if (ta instanceof HTMLTextAreaElement) {
        const formatStatNumber = (num) => {
          const n = Number(num);
          return Number.isNaN(n) || n < 0 ? ' ― ' : n.toLocaleString('ja-JP');
        };
        const view = formatStatNumber(data.stats.view);
        const comment = formatStatNumber(data.stats.comment);
        ta.placeholder = `コメントを入力 ( 👥${view} / 💬${comment} )`;
      }
    }
  };

  /**
   * 新着コメントを1つずつもしくは一気に表示する
   * @param {HTMLElement} e コメントリストの親要素
   * @param {string} t どのページを開いているか
   */
  const visibleComment = (e, t) => {
    const clickContinueButton = () => {
      /** @type {HTMLButtonElement|null} */
      const cButton = document.querySelector(selector.commentContinue);
      if (cButton) cButton.click();
    };
    if (setting.newCommentOneByOne) {
      const hidden = document.querySelectorAll(selector.commentHidden),
        time =
          t === 'tv' || t === 'ts'
            ? hidden.length > 7
              ? (6.5 / hidden.length) * 1000
              : 920
            : t === 'le'
              ? hidden.length > 5
                ? (4.5 / hidden.length) * 1000
                : 900
              : 1000;
      clearInterval(interval.newcomment);
      interval.newcomment = setInterval(() => {
        const ch = document.querySelector(selector.commentHidden),
          rf = document.querySelector(selector.commentReport);
        if (!rf && !data.commentMouseEnter) {
          const settingsEl = document.getElementById(`${sid}_Settings`);
          const isSettingsOpen =
            settingsEl &&
            !settingsEl.classList.contains(`${sid}_Settings_hidden`);
          if (ch) {
            /** @type {HTMLDivElement|null} */
            const chi = ch.querySelector(selector.commentInner);
            if (chi) chi.dataset[`${sid.toLowerCase()}Hidden`] = 'false';
            if (e.scrollHeight - e.scrollTop - e.clientHeight < 500) {
              if (
                setting.scrollNewComment &&
                !isSettingsOpen &&
                hidden.length < 30
              ) {
                e.scroll({
                  top: e.scrollHeight,
                  behavior: 'auto',
                });
                e.scrollBy({
                  top: -ch.clientHeight,
                  behavior: 'auto',
                });
                e.scrollBy({
                  top: ch.clientHeight + 1,
                  behavior: 'smooth',
                });
              } else {
                e.scrollBy({
                  top: ch.clientHeight + 1,
                  behavior: 'auto',
                });
              }
              clickContinueButton();
            }
          } else {
            clearInterval(interval.newcomment);
            if (e.scrollHeight - e.scrollTop - e.clientHeight < 500) {
              if (
                setting.scrollNewComment &&
                !isSettingsOpen &&
                hidden.length < 30
              ) {
                e.scroll({
                  top: e.scrollHeight,
                  behavior: 'smooth',
                });
              } else {
                e.scroll({
                  top: e.scrollHeight,
                  behavior: 'auto',
                });
              }
              clickContinueButton();
            }
          }
        }
      }, time);
    } else {
      const ch = document.querySelectorAll(selector.commentHidden),
        rf = document.querySelector(selector.commentReport);
      if (ch.length && !rf && !data.commentMouseEnter) {
        const ccb =
          e.scrollHeight - e.scrollTop - e.clientHeight < 500 ? true : false;
        for (let i = 0, j = ch.length; i < j; i++) {
          /** @type {HTMLDivElement|null} */
          const chi = ch[i].querySelector(selector.commentInner);
          if (chi) chi.dataset[`${sid.toLowerCase()}Hidden`] = 'false';
        }
        if (ccb) clickContinueButton();
      }
    }
  };

  const observerC = new MutationObserver(checkNewComments),
    observerE = new MutationObserver(changeElements),
    observerR = new ResizeObserver(resizeVideo),
    observerT = new MutationObserver(changePageTitle),
    observerV = new MutationObserver(changeVideoSource);

  window.addEventListener(EVENTS.BROADCAST_DATA_UPDATED, (e) => {
    if (e instanceof CustomEvent && e.detail && e.detail.slots) {
      checkNewChannels(e.detail.slots);
    }
  });
  clearInterval(interval.init);
  interval.init = setInterval(() => {
    if (document.querySelector(selector.main)) {
      clearInterval(interval.init);
      init();
    }
  }, 500);
})();