ABEMA Little Tools

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         ABEMA Little Tools
// @namespace    https://greasyfork.org/ja/scripts/465585
// @version      19
// @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 stateSid = `${sid}-State`;
  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} [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) {
      console.error(`${sid}: Failed to parse localStorage for ${key}`, e);
      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`);
  /** @type {Object<string, any>} */
  const lsState = getLS(stateSid);
  /** @type {Object<string, any>} */
  const lsSpam = Object.assign(Object.create(null), getLS(`${sid}-SpamLevel`));

  /**
   * スクリプト内の共通データ
   */
  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 {string[]} */
    channelOrder: lsState.channelOrder || [],
    /** @type {{userid: string, message: string[]}[]} */
    comment: [{ userid: '', message: [''] }],
    commentAll: 0,
    /** @type {Set<string>} */
    commentId: new Set(),
    commentMouseEnter: false,
    dataProviderRunning: false,
    footerFeed: '',
    href: '',
    uninterestedChannels: new Set(
      Object.entries(ls.channelGroups || {})
        .filter(([, group]) => group === 16)
        .map(([id]) => id),
    ),
    imageDomain: '',
    lastBroadcastFetchTime: 0,
    newComments: false,
    /** @type {{channelId: string, current: TimetableSlot | null, programs: {title: string, startAt: number, endAt: number, displayProgramId?: string, displayImageUpdatedAt?: number}[]}[]} */
    nextPrograms: [],
    /** @type {Set<string>} */
    ngId: new Set(lsId.ngId),
    /** @type {string[]} */
    ngWordText: [],
    /** @type {RegExp[]} */
    ngWordRe: [],
    /** @type {RegExp[]} */
    ngWordWarningRe: lsState.warningRe ? [...lsState.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: '',
    /** @type {Object<string, {lastMessage: string, consecutiveCount: number, threshold: number}>} */
    spamData: Object.create(null),
    /** @type {MutationObserver|null} */
    timetableObserver: null,
    title: '',
    version: 19,
    videoSource: '',
    /** 番組表の表示期間オフセット (-3〜+3) 0=基準期間 */
    customTimetablePeriodOffset: 0,
  };

  /**
   * 定期実行(タイマー)のIDを管理
   */
  const interval = {
    applyTimetableUninterest: 0,
    changePageTitle: 0,
    changeTargetQuality: 0,
    checkSwitchedProgram: 0,
    checkSwitchedProgramNext: 0,
    comment: 0,
    customTimetableUpdate: 0,
    init: 0,
    navigation: 0,
    newcomment: 0,
    nextPrograms: 0,
    notification: 0,
    programInfo2: 0,
    resizeVideo: 0,
    resolution: 0,
    updateChannelOrder: 0,
    videoelement: 0,
    videoskip: 0,
    videosource: 0,
    linearchannellist: 0,
    debounceShowNextPrograms: 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, .com-pages-episode-NextContentCard__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',
    sideNaviTimetable: '.com-application-SideNavigation a[href^="/timetable"]',
    sideNaviWrapClosed: 'com-application-SideNavigation__wrapper--closed',
    sideNaviPrimaryItem: '.com-application-SideNavPrimaryItem',
    sideNaviTimetableItem:
      '.com-application-SideNavPrimaryItem:has(a[href^="/timetable"])',
    sideNaviWrapper: '.com-application-CollapsibleWrapper',
    sideNaviLink: '.com-application-SideNavPrimaryItem__link',
    sideNaviContent: '.com-application-SideNavPrimaryItem__content',
    sideNaviTextSide: '.com-application-SideNavPrimaryItem__side-text',
    sideNaviTextBottom: '.com-application-SideNavPrimaryItem__bottom-text',
    sidePanel:
      '.c-tv-NowOnAirContainer__side-panel, .com-comment-CommentContainerView',
    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',
    tvChannelList: '.com-tv-LinearChannelList',
    tvChannelListShrunk: '.com-tv-LinearChannelList--shrunk',
    tvContainerScreen: '.c-tv-NowOnAirContainer__screen',
    tvScreen: '.com-tv-TVScreen',
    tvScreenOverlay: '.com-tv-TVScreen__overlay',
    timetableHeaderItem: 'div.com-timetable-ChannelIcon',
    timetableHeaderContainer: '.com-timetable-ChannelIconHeader',
    timetableColumn: '.com-timetable-TimetableColumn',
    timetableContainer: '.com-timetable-TimeTableListTimeTable-wrapper',
    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, theme: number,
   * closeSidePanel: boolean, sidePanelCloseButton: boolean, showProgramDetail: boolean, hiddenButtonText: boolean,
   * viewCounter: boolean, programInfo1: boolean, programInfo2: boolean, programInfo2Num: number|string,
   * nextPrograms: boolean, nextProgramsNum: number|string, updateCurrentChannelList: boolean, searchProgram: boolean,
   * timetableCompactHeader: boolean, timetableStickyProgram: boolean, timetableUninterestedChannel: boolean,
   * customTimetable: boolean, customTimetableAvoidSidePanel: boolean,timetableDaysBefore: number, timetableDaysAfter: number,
   * channelGroups: { [key: string]: number },
   * groupData: { [key: string]: { name: string, searchWords: string[] } },
   * 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, hideDuplicateComment: 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,
    theme: ls.theme || 0,
    // テレビ
    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,
    updateCurrentChannelList: ls.updateCurrentChannelList,
    searchProgram: ls.searchProgram,
    // 番組表
    timetableCompactHeader: ls.timetableCompactHeader,
    timetableStickyProgram: ls.timetableStickyProgram,
    timetableUninterestedChannel: ls.timetableUninterestedChannel,
    customTimetable: ls.customTimetable,
    customTimetableAvoidSidePanel: ls.customTimetableAvoidSidePanel,
    timetableDaysBefore: ls.timetableDaysBefore,
    timetableDaysAfter: ls.timetableDaysAfter,
    channelGroups: ls.channelGroups || {},
    groupData:
      ls.groupData ||
      Object.fromEntries(
        Array.from({ length: 15 }, (_, i) => [
          i + 1,
          { name: `グループ${i + 1}`, searchWords: [] },
        ]),
      ),
    // ビデオ・見逃し視聴
    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,
    hideDuplicateComment: ls.hideDuplicateComment,
    // 画質
    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,
  };

  /** グループ設定の一時保存用 */
  let tempGroupData = JSON.parse(JSON.stringify(setting.groupData));

  /**
   * ビデオ要素の状態管理
   */
  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);
      saveIdSettings();
      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}-dark: oklch(35% 5% 0);
  --${sid}-light: oklch(65% 5% 0);
  --${sid}-theme-mode: light;
  --${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],
  > div[data-${sid.toLowerCase()}-spam]
),
.com-archive-comment-ArchiveCommentItem:has(
  > p[data-${sid.toLowerCase()}-hidden="true"],
  > p[data-${sid.toLowerCase()}-ngword],
  > p[data-${sid.toLowerCase()}-ngid],
  > p[data-${sid.toLowerCase()}-spam]
),
.${sid}_ProgramInfo_hidden,
.${sid}_Settings_hidden,
.${sid}_Settings-tab-switch {
  display: none !important;
}
:is(#${sid}_Settings, #${sid}_ProgramInfo1, #${sid}_ProgramInfo2, #${sid}_SearchResults, #${sid}_CustomTimetable) a {
  text-decoration: none;
  &:hover {
    text-decoration: underline;
  }
}
.${sid}_scrolling-paused {
  .com-o-CommentForm__opened-textarea-wrapper,
  .com-comment-CommentTextarea {
    border-color: #c63 !important;
  }
  .com-o-CommentForm__opened-textarea-wrapper textarea:focus,
  .com-comment-CommentTextarea textarea:focus {
    outline-color: #c63 !important;
  }
}

/*設定欄*/
#${sid}_Settings {
  background-color: light-dark(oklch(98% 1% 90), oklch(23% 0 0));
  border: 2px solid light-dark(#CCCCCC, #555555);
  border-radius: 8px;
  box-shadow: 4px 4px 16px rgb(0 0 0 / 50%);
  color: light-dark(black, #EEEEEE);
  color-scheme: var(--${sid}-theme-mode);
  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-close {
  background: none;
  border: none;
  color: light-dark(#666, #ccc);
  cursor: pointer;
  font-size: 1.5rem;
  line-height: 1;
  padding: 4px;
  position: absolute;
  right: -6px;
  top: -6px;
  &:hover {
    background-color: red;
    color: light-dark(black, white);
  }
}
#${sid}_Settings-main {
  display: flex;
  flex-wrap: wrap;
  margin: 8px 0;
  &::after {
    background: light-dark(#8899aa, #556677);
    content: '';
    display: block;
    height: 1px;
    order: -1;
    width: 100%;
  }
  fieldset {
    border: 1px solid light-dark(#CCCCCC, #555555);
    margin: 2px 0;
    padding: 4px 8px;
  }
  fieldset + label {
    margin-top: 2px;
  }
  fieldset + fieldset {
    margin-top: 10px;
  }
  pre {
    background-color: light-dark(#FFFFEE, #333322);
    border: 1px solid light-dark(#DDDDDD, #555555);
    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: light-dark(oklch(85% 5% 260), oklch(30% 5% 260));
  }
  details {
    padding: 0.25em 0;
    transition: 0.5s;
    code {
      background: light-dark(oklch(97% 0 0 / 80%), oklch(30% 0 0 / 80%));
      border-radius: 3px;
      font-family: monospace;
      padding: 2px 4px;
    }
  }
  input[type="checkbox"] {
    position: relative;
    top: 2px;
    margin-right: 4px;
  }
  summary {
    cursor: pointer;
    display: list-item;
  }
  summary.${sid}_Settings-summary-help::before {
    background: light-dark(#88AA88, #557755);
    border-radius: 50%;
    color: light-dark(#FFFFFF, #EEEEEE);
    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: 3px 6px;
  }
  input + input,
  input + input + input,
  legend:has(input),
  legend:has(input) ~ label {
    color: light-dark(gray, #AAAAAA);
  }
  input:checked + input,
  input:checked + input + input,
  legend:has(input:checked),
  legend:has(input:checked) ~ label {
    color: light-dark(black, #EEEEEE);
  }
  input + input,
  input + input + input,
  legend:has(input):has(
    ~ #${sid}_Settings-ngWord,
    ~ label #${sid}_Settings-ngIdMaxSize,
    ~ label #${sid}_Settings-targetQuality
  ) {
    background-color: light-dark(#DDDDDD, #444444);
  }
  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: light-dark(white, #222222);
  }
}
.${sid}_Settings-summary-sub {
  opacity: 0.5;
}
.${sid}_Settings-details-nest {
  border-radius: 16px;
  outline: 1px solid light-dark(#CCCCCC, #555555);
  outline-offset: -6px;
  padding: 1em !important;
  width: calc(100% - 2em) !important;
  &[open] {
    background-color: light-dark(oklch(95% 1% 90), oklch(26% 0 0));
  }
  &:hover {
    background-color: light-dark(oklch(80% 5% 260), oklch(35% 5% 260)) !important;
  }
  p:has(b) {
    margin-top: 1em;
  }
}
#${sid}_Settings-group {
  .${sid}_Settings-_label {
    display: inline-block;
    width: 6em;
  }
}
select#${sid}_Settings-groupList {
  width: 13em;
}
select#${sid}_Settings-groupSearchList {
  margin-left: 0;
}
#${sid}_Settings-groupSearchWord {
  width: calc(100% - 10em - 20px);
}
#${sid}_Settings-channelList {
  li {
    align-items: center;
    display: flex;
    justify-content: space-between;
    select {
      margin: 1px 0;
      padding: 2px 8px;
    }
  }
}
#${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;
}
label:has(#${sid}_Settings-qualityEnable),
label:has(#${sid}_Settings-ngWordEnable),
label:has(#${sid}_Settings-ngIdEnable) {
  color: light-dark(black, #EEEEEE);
}
#${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: light-dark(red, #FF6666);
}
#${sid}_Settings-ngWord-warning p {
  color: light-dark(orange, #FFCC66);
}
#${sid}_Settings-ngIdMaxSize {
  text-align: right;
}
#${sid}_Settings-ngId-record {
  margin-left: 1em;
}
#${sid}_Settings-footer-notice {
  display: none;
  background-color: light-dark(#FFFDEE, #444433);
  border: 1px solid light-dark(#CCCCCC, #555555);
  border-radius: 8px;
  margin: 4px 8px;
  padding: 8px;
}
#${sid}_Settings-footer-buttons {
  text-align: right;
  button {
    border: 1px solid light-dark(gray, #777777);
    border-radius: 4px;
    margin: 8px;
    padding: 4px;
    width: 8em;
  }
}
#${sid}_Settings-ok {
  background-color: light-dark(oklch(68% 8% 248), oklch(44% 9% 248));
  color: white;
  &:hover {
    background-color: light-dark(oklch(65% 8% 248), oklch(41% 9% 248));
  }
}
#${sid}_Settings-cancel {
  background-color: light-dark(oklch(95% 1% 90), oklch(26% 0 0));
  &:hover {
    background-color: light-dark(oklch(92% 1% 90), oklch(29% 0 0));
  }
}
.${sid}_Settings-tab-label {
  background: light-dark(oklch(80% 0 0), oklch(35% 0 0));
  border-radius: 4px 4px 0 0;
  color: light-dark(White, #DDDDDD);
  cursor: pointer;
  flex: 1;
  font-weight: bold;
  order: -1;
  padding: 2px 4px;
  position: relative;
  text-align: center;
  text-shadow: 0 -1px 0 rgb(0 0 0 / 20%);
  white-space: nowrap;
  z-index: 1;
  &:hover {
    background: light-dark(oklch(75% 0 0), oklch(40% 0 0));
  }
  &: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: light-dark(#8899aa, #445566);
}
.${sid}_Settings-tab-switch:checked + .${sid}_Settings-tab-label + .${sid}_Settings-tab-content {
  box-shadow: 0 0 3px rgb(0 0 0 / 20%);
  height: auto;
  opacity: 1;
  overflow: auto;
  padding: 8px;
  transition: .5s opacity;
}
:root[data-${sid.toLowerCase()}-theme="default"] {
  #${sid}_Settings {
    background-color: oklch(98% 1% 250);
  }
  #${sid}_Settings-cancel {
    background-color: light-dark(oklch(95% 1% 250), oklch(26% 0 0));
    &:hover {
      background-color: light-dark(oklch(92% 1% 250), oklch(29% 0 0));
    }
  }
}

/*コメント報告フォーム*/
#${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, rgb(0 0 0 / 20%), #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: 30;
}
:is(#${sid}_ProgramInfo1, #${sid}_ProgramInfo2):not(.${sid}_ProgramInfo_hidden) {
  display: flex;
}
#${sid}_ProgramInfo1 {
  background: linear-gradient(180deg, oklch(15% 0 0 / 50%), oklch(30% 0 0 / 50%));
  box-shadow: 0 0 2px 2px rgb(0 0 0 / 50%);
  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);
  }
}
.${sid}_ProgramInfo-header {
  align-items: center;
  border-bottom: 1px solid rgb(255 255 255 / 10%);
  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}_SearchResults,
#${sid}_CustomTimetable-detail {
  .${sid}_ProgramInfo-label-bundle,
  .${sid}_ProgramInfo-label-binge,
  .${sid}_ProgramInfo-label-pick {
    background-color: light-dark(black, white);
    color: light-dark(white, black);
  }
  .${sid}_ProgramInfo-label-first,
  .${sid}_ProgramInfo-label-last {
    border-color: light-dark(black, white);
    color: light-dark(black, white);
  }
}
.${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;
  }
}

/*カスタム番組表*/
#${sid}_CustomTimetable {
  background-color: light-dark(oklch(94% 0 0 / 75%), oklch(20% 0 0 / 75%));
  backdrop-filter: blur(4px);
  border: 1px solid rgb(255 255 255 / 20%);
  border-radius: 8px;
  bottom: 64px;
  box-shadow: 0 8px 32px rgb(0 0 0 / 50%);
  color: light-dark(black, white);
  color-scheme: var(--${sid}-theme-mode);
  display: flex;
  flex-direction: column;
  left: 64px;
  max-width: calc(100vw - 128px);
  position: absolute;
  top: 55px;
  z-index: 15;
  button {
    color: light-dark(#666, #ccc);
    &:hover {
      color: light-dark(black, white);
    }
  }
  select {
    color: light-dark(black, white);
  }
}
#main:has(.com-vod-VODResponsiveMainContent) ~ #${sid}_CustomTimetable {
  top: 100px;
}
#${sid}_CustomTimetable-header {
  align-items: center;
  background-color: light-dark(oklch(94% 0 0 / 75%), oklch(20% 0 0 / 75%));
  border-bottom: 1px solid light-dark(#333, #CCC);
  border-radius: 8px 8px 0 0;
  container-type: inline-size;
  display: flex;
  min-width: 33em;
  padding: 2px 8px;
}
#${sid}_CustomTimetable-title {
  font-size: 1.2rem;
  font-weight: bold;
  white-space: nowrap;
}
#${sid}_CustomTimetable-groupSelect,
#${sid}_CustomTimetable-period-select {
  appearance: auto;
  background-color: light-dark(oklch(94% 0 0), oklch(23% 0 0));
  color: light-dark(black, white);
  cursor: pointer;
  font-size: 1rem;
  padding: 4px;
}
#${sid}_CustomTimetable-groupSelect:hover,
#${sid}_CustomTimetable-period-select:hover {
  background-color: light-dark(rgb(255 255 255), oklch(26% 0 0));
}
#${sid}_CustomTimetable-prevGroup,
#${sid}_CustomTimetable-nextGroup,
#${sid}_CustomTimetable-period button {
  background-color: light-dark(oklch(94% 0 0), oklch(23% 0 0));
  border: 1px solid light-dark(rgb(0 0 0 / 40%), rgb(255 255 255 / 40%));
  border-radius: 4px;
  color: light-dark(black, white);
  cursor: pointer;
  font-size: 1rem;
  margin: 0 4px;
  padding: 2px 8px;
  &:hover {
    background-color: light-dark(oklch(97% 0 0), oklch(30% 5% 90));
  }
}
#${sid}_CustomTimetable-group,
#${sid}_CustomTimetable-period {
  margin-left: 12px;
}
#${sid}_CustomTimetable-period button {
  margin: 0;
}
#${sid}_CustomTimetable-group,
#${sid}_CustomTimetable-period {
  button, select {
    font-size: 90%;
  }
}
@container (max-width: 40em) {
  #${sid}_CustomTimetable-prevGroup,
  #${sid}_CustomTimetable-nextGroup,
  #${sid}_CustomTimetable-period-prev,
  #${sid}_CustomTimetable-period-next {
    display: none;
  }
}
#${sid}_CustomTimetable-help {
  cursor: pointer;
  font-size: 1rem;
  height: 28px;
  line-height: 28px;
  margin-left: auto;
  padding: 0;
  text-align: center;
  width: 28px;
  &:hover {
    background-color: light-dark(oklch(85% 0 0), oklch(30% 0 0));
  }
}
#${sid}_CustomTimetable-close {
  background: none;
  border: none;
  cursor: pointer;
  font-size: 1.5rem;
  line-height: 1;
  margin-left: 8px;
  padding: 4px;
  &:hover {
    background-color: red;
  }
}
#${sid}_CustomTimetable-detail,
#${sid}_CustomTimetable-help-panel {
  background-color: light-dark(oklch(95% 0 0 / 75%), oklch(30% 0 0 / 75%));
  backdrop-filter: blur(4px);
  border: 1px solid #666;
  display: none;
  flex-direction: column;
  height: fit-content;
  max-width: calc(100% - 16px);
  position: absolute;
  width: fit-content;
}
#${sid}_CustomTimetable-detail {
  left: 8px;
  top: 106px;
  z-index: 100;
}
#${sid}_CustomTimetable-help-panel {
  right: 20px;
  top: 40px;
  z-index: 101;
}
#${sid}_CustomTimetable-detail:not(.${sid}_CustomTimetable-detail-hidden),
#${sid}_CustomTimetable-help-panel:not(.${sid}_CustomTimetable-help-panel-hidden) {
  display: flex;
}
#${sid}_CustomTimetable-detail-header,
#${sid}_CustomTimetable-help-panel-header {
  align-items: center;
  background-color: light-dark(oklch(85% 0 0 / 50%), oklch(20% 0 0 / 50%));
  border-bottom: 1px solid #444;
  display: flex;
  padding: 2px 8px;
}
#${sid}_CustomTimetable-detail-title,
#${sid}_CustomTimetable-help-panel-title {
  font-weight: bold;
}
#${sid}_CustomTimetable-detail-close,
#${sid}_CustomTimetable-help-panel-close {
  background: none;
  border: none;
  cursor: pointer;
  font-size: 1.2rem;
  margin-left: auto;
  padding: 0 4px;
  &:hover {
    background-color: red;
  }
}
#${sid}_CustomTimetable-detail-body,
#${sid}_CustomTimetable-help-panel-body {
  flex: 1;
  overflow-y: auto;
  padding: 12px;
}
#${sid}_CustomTimetable-help-panel-body {
  line-height: 1.6;
  li {
    list-style: inside !important;
    margin-left: 1em;
  }
}
#${sid}_CustomTimetable-content {
  flex: 1;
  overflow: auto;
  position: relative;
}
#${sid}_CustomTimetable-header-row {
  color: light-dark(black, white);
  display: flex;
  position: sticky;
  top: 0;
  z-index: 10;
  border-bottom: 2px solid light-dark(#333, #ccc);
  box-shadow: 0 2px 4px rgb(0 0 0 / 10%);
  min-width: min-content;
}
#${sid}_CustomTimetable-topleft {
  align-items: center;
  background-color: light-dark(#EBEBEB, #141414);
  border-right: 2px solid light-dark(#333, #ccc);
  display: flex;
  flex-shrink: 0;
  flex-direction: column;
  font-size: 10px;
  font-weight: bold;
  justify-content: center;
  width: 50px;
}
#${sid}_CustomTimetable-channels-wrap {
  background-color: light-dark(#EBEBEB, #141414);
  display: flex;
  flex: 1;
}
.${sid}_CustomTimetable-channel-header {
  align-items: center;
  border-right: 1px solid light-dark(#222, #ddd);
  display: flex;
  flex: 1;
  justify-content: center;
  max-width: 200px;
  min-height: 48px;
  min-width: 100px;
  padding: 4px 0;
}
.${sid}_CustomTimetable-channel-logo {
  height: auto;
  max-height: 48px;
  max-width: 100%;
}
:root[data-${sid.toLowerCase()}-theme="light"] .${sid}_CustomTimetable-channel-logo {
  filter: invert(1);
}
@media (prefers-color-scheme: light) {
  :root:not([data-${sid.toLowerCase()}-theme="dark"]):not([data-${sid.toLowerCase()}-theme="default"]) .${sid}_CustomTimetable-channel-logo {
    filter: invert(1);
  }
}
#${sid}_CustomTimetable-body {
  display: flex;
  position: relative;
}
#${sid}_CustomTimetable-timeaxis {
  background-color: light-dark(oklch(85% 0 0 / 75%), oklch(20% 0 0 / 75%));
  border-right: 2px solid light-dark(#333, #ccc);
  flex-shrink: 0;
  position: relative;
  width: 50px;
}
.${sid}_CustomTimetable-hour {
  border-top: 1px solid light-dark(#333, #ccc);
  position: absolute;
  width: 100%;
}
.${sid}_CustomTimetable-hour-past {
  /*background-color: rgb(221 110 110 / 10%);*/
  background-color: light-dark(oklch(70% 10% 40), oklch(30% 10% 40));
}
.${sid}_CustomTimetable-hour-prev {
  /*background-color: rgb(221 110 110 / 15%);*/
  background-color: light-dark(oklch(80% 10% 40), oklch(40% 10% 40));
}
.${sid}_CustomTimetable-hour-now {
  /*background-color: rgb(221 170 0 / 20%);*/
  background-color: light-dark(oklch(80% 10% 100), oklch(40% 10% 100));
}
.${sid}_CustomTimetable-hour-next {
  /*background-color: rgb(110 110 221 / 15%);*/
  background-color: light-dark(oklch(80% 10% 260), oklch(40% 10% 260));
}
.${sid}_CustomTimetable-hour-future {
  /*background-color: rgb(110 110 221 / 10%);*/
  background-color: light-dark(oklch(70% 10% 260), oklch(30% 10% 260));
}
.${sid}_CustomTimetable-hour-date {
  color: light-dark(#111, #eee);
  font-size: 11px;
  line-height: 1.1;
  position: absolute;
  text-align: center;
  top: 2px;
  width: 100%;
}
.${sid}_CustomTimetable-hour-text {
  color: light-dark(#111, #eee);
  font-size: 18px;
  font-weight: bold;
  position: absolute;
  text-align: center;
  top: 50%;
  transform: translateY(-50%);
  width: 100%;
}
#${sid}_CustomTimetable-columns {
  display: flex;
  flex: 1;
  position: relative;
}
.${sid}_CustomTimetable-column {
  flex: 1;
  max-width: 200px;
  min-width: 100px;
  position: relative;
}
#${sid}_CustomTimetable-nowline {
  background-color: light-dark(oklch(80% 10% 100), oklch(60% 10% 100));
  height: 2px;
  left: 0;
  pointer-events: none;
  position: absolute;
  right: 0;
  z-index: 5;
}
#${sid}_CustomTimetable-nowline::before {
  border-bottom: 5px solid transparent;
  border-left: 8px solid light-dark(oklch(80% 10% 100), oklch(60% 10% 100));
  border-top: 5px solid transparent;
  content: '';
  left: 0;
  position: absolute;
  top: -4px;
}
.${sid}_CustomTimetable-program {
  border-radius: 2px;
  box-sizing: border-box;
  color: light-dark(#000, #fff);
  cursor: pointer;
  overflow: clip;
  overflow-clip-margin: border-box;
  position: relative;
  transition: background-color 0.2s;
  width: 100%;
  &:hover {
    overflow: visible;
  }
}
.${sid}_CustomTimetable-program-past {
  background-color: light-dark(oklch(90% 5% 50 / 80%), oklch(20% 5% 50 / 80%));
  border: 1px solid light-dark(#ccc, #333);
}
.${sid}_CustomTimetable-program-broadcasting {
  background-color: light-dark(oklch(95% 5% 90 / 80%), oklch(30% 5% 90 / 80%));
  border: 1px solid light-dark(#ddd, #222);
}
.${sid}_CustomTimetable-program-future {
  background-color: light-dark(oklch(90% 5% 250 / 80%), oklch(20% 5% 250 / 80%));
  border: 1px solid light-dark(#eee, #111);
}
.${sid}_CustomTimetable-program-unmatched {
  color: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
  span[class^="${sid}_CustomTimetable-label"] {
    opacity: 0.2;
  }
  &:hover {
    color: light-dark(oklch(0 0 0 / 80%), oklch(1 0 0 / 80%));
    span[class^="${sid}_CustomTimetable-label"] {
      opacity: 0.8;
    }
  }
}
.${sid}_CustomTimetable-spacer {
  box-sizing: border-box;
  width: 100%;
}
.${sid}_CustomTimetable-program-inner {
  display: flex;
  flex-direction: row;
  gap: 0.5em;
  padding: 2px;
  position: sticky;
  top: 60px;
  &:hover {
    z-index: 2;
  }
}
.${sid}_CustomTimetable-program-past:hover {
  background-color: light-dark(oklch(90% 10% 50), oklch(30% 10% 50));
  border: 1px solid light-dark(#ccc, #333);
  .${sid}_CustomTimetable-program-inner:hover {
    background-color: light-dark(oklch(90% 10% 50), oklch(30% 10% 50));
  }
}
.${sid}_CustomTimetable-program-broadcasting:hover {
  background-color: light-dark(oklch(95% 10% 90), oklch(40% 10% 90));
  border: 1px solid light-dark(#ddd, #222);
  .${sid}_CustomTimetable-program-inner:hover {
    background-color: light-dark(oklch(95% 10% 90), oklch(40% 10% 90));
  }
}
.${sid}_CustomTimetable-program-future:hover {
  background-color: light-dark(oklch(90% 10% 250), oklch(30% 10% 250));
  border: 1px solid light-dark(#eee, #111);
  z-index: 2;
  .${sid}_CustomTimetable-program-inner:hover {
    background-color: light-dark(oklch(90% 10% 250), oklch(30% 10% 250));
  }
}
.${sid}_CustomTimetable-program-time {
  font-size: 11px;
  color: light-dark(#555, #aaa);
  white-space: nowrap;
}
.${sid}_CustomTimetable-program-title {
  font-size: 13px;
  line-height: 1.3;
  word-break: break-word;
  span[class^="${sid}_CustomTimetable-label"] {
    margin-right: 2px;
    font-size: 11px;
    padding: 1px 2px;
  }
  .${sid}_CustomTimetable-label-new,
  .${sid}_CustomTimetable-label-live {
    background-color: red;
    color: white;
    text-shadow: 1px 1px black;
  }
  .${sid}_CustomTimetable-label-bundle,
  .${sid}_CustomTimetable-label-binge,
  .${sid}_CustomTimetable-label-pick {
    background-color: light-dark(black, white);
    color: light-dark(white, black);
    text-shadow: 0px 0px;
  }
  .${sid}_CustomTimetable-label-first,
  .${sid}_CustomTimetable-label-last {
    background-color: transparent;
    border: 1px solid light-dark(black, white);
    color: light-dark(black, white);
    margin-right: 4px;
    text-shadow: 0px 0px;
  }
  .${sid}_CustomTimetable-label-last {
    margin-left: 4px;
    margin-right: 0;
  }
}
:root[data-${sid.toLowerCase()}-theme="default"] {
  #${sid}_CustomTimetable-header {
    background-color: oklch(90% 3% 90 / 80%);
  }
  #${sid}_CustomTimetable-header-row {
    color-scheme: dark;
  }
  #${sid}_CustomTimetable-help:hover {
    background-color: oklch(80% 3% 90);
  }
}

/*番組の検索結果をページ遷移せずに表示する*/
#${sid}_SearchResults {
  background-color: light-dark(oklch(1 0 0 / 80%), oklch(25% 0 0 / 80%));
  backdrop-filter: blur(4px);
  border: 1px solid light-dark(rgb(0 0 0 / 20%), rgb(255 255 255 / 20%));
  border-radius: 8px;
  box-shadow: 0 8px 32px rgb(0 0 0 / 50%);
  color: light-dark(black, white);
  color-scheme: var(--${sid}-theme-mode);
  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: 950;
  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 light-dark(rgb(0 0 0 / 10%), rgb(255 255 255 / 10%));
  display: flex;
  justify-content: space-between;
  padding: 2px 8px;
}
.${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: light-dark(#666, #ccc);
    cursor: pointer;
    line-height: 1;
    padding: 4px;
    &:hover {
      color: light-dark(black, 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;
  &:hover {
    background-color: red;
  }
}
#${sid}_SearchResults-help-btn {
  font-size: 1.2rem;
  height: 32px;
  margin-right: 8px;
  width: 32px;
  &[title="検索結果に戻る"] {
    font-size: inherit;
    vertical-align: 2px;
  }
  &:hover {
    background-color: light-dark(oklch(85% 0 0), oklch(30% 0 0));
  }
}
.${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 light-dark(#ccc, #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: light-dark(oklch(97% 0 0 / 80%), oklch(30% 0 0 / 80%));
    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: light-dark(#666, #aaa);
  padding: 24px;
  text-align: center;
}
.${sid}_SearchResults-item {
  border-bottom: 1px solid light-dark(rgb(0 0 0 / 5%), rgb(255 255 255 / 5%));
  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: light-dark(oklch(50% 25% 50), oklch(35% 20% 50));
}
.is-pre-6 .${sid}_SearchResults-item-sidebar {
  background: light-dark(oklch(55% 25% 50), oklch(40% 20% 50));
}
.is-pre-5 .${sid}_SearchResults-item-sidebar {
  background: light-dark(oklch(60% 25% 50), oklch(45% 20% 50));
}
.is-pre-4 .${sid}_SearchResults-item-sidebar {
  background: light-dark(oklch(65% 25% 50), oklch(50% 20% 50));
}
.is-pre-3 .${sid}_SearchResults-item-sidebar {
  background: light-dark(oklch(70% 25% 50), oklch(55% 20% 50));
}
.is-pre-2 .${sid}_SearchResults-item-sidebar {
  background: light-dark(oklch(75% 25% 50), oklch(60% 20% 50));
}
.is-pre-1 .${sid}_SearchResults-item-sidebar {
  background: light-dark(oklch(80% 25% 50), oklch(65% 20% 50));
}
.is-today .${sid}_SearchResults-item-sidebar {
  background: light-dark(oklch(90% 25% 90), oklch(80% 20% 90));
  box-shadow: 0 0 3px light-dark(oklch(90% 25% 90 / 50%), oklch(80% 20% 90 / 50%));
}
.is-next-1 .${sid}_SearchResults-item-sidebar {
  background: light-dark(oklch(80% 25% 250), oklch(65% 20% 250));
}
.is-next-2 .${sid}_SearchResults-item-sidebar {
  background: light-dark(oklch(75% 25% 250), oklch(60% 20% 250));
}
.is-next-3 .${sid}_SearchResults-item-sidebar {
  background: light-dark(oklch(70% 25% 250), oklch(55% 20% 250));
}
.is-next-4 .${sid}_SearchResults-item-sidebar {
  background: light-dark(oklch(65% 25% 250), oklch(50% 20% 250));
}
.is-next-5 .${sid}_SearchResults-item-sidebar {
  background: light-dark(oklch(60% 25% 250), oklch(45% 20% 250));
}
.is-next-6 .${sid}_SearchResults-item-sidebar {
  background: light-dark(oklch(55% 25% 250), oklch(40% 20% 250));
}
.is-next-7 .${sid}_SearchResults-item-sidebar {
  background: light-dark(oklch(50% 25% 250), oklch(35% 20% 250));
}
.${sid}_SearchResults-item-header {
  align-items: center;
  display: flex;
  font-size: 0.85rem;
  gap: 12px;
  margin-bottom: 4px;
}
.${sid}_SearchResults-item-channel {
  color: light-dark(#666, #aaa);
  a {
    text-decoration: none;
    &:hover {
      color: light-dark(#333, #ccc);
      text-decoration: underline;
    }
  }
}
.${sid}_SearchResults-item-time {
  color: light-dark(#666, #aaa);
}
.${sid}_SearchResults-item-title {
  font-size: 1rem;
  margin-bottom: 4px;
  a {
    color: light-dark(black, #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: light-dark(#555, #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: light-dark(oklch(60% 40% 140), oklch(50% 30% 140));
  color: white;
}
.${sid}_SearchResults-item-ts-prem {
  background: light-dark(oklch(65% 30% 90), oklch(55% 20% 90));
  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: light-dark(#666, #ccc);
  cursor: pointer;
  font-size: 1rem;
  line-height: 1;
  padding: 0;
  z-index: 10;
  &:hover {
    color: light-dark(black, white);
  }
}
.${sid}_SearchResults-detail-content {
  margin-top: -0.5rem;
}
:root[data-${sid.toLowerCase()}-theme="default"] {
  #${sid}_SearchResults-help-btn:hover {
    background-color: oklch(80% 3% 90);
  }
}
    `,
      // 全般
      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, rgb(0 0 0 / 20%), #000000) !important;
}
.com-InputText__input--dark-strong:not(:focus),
.com-live-event-LiveEventOverlayControllerLayout__bottom-buttons .com-a-Button--dark:not(:hover) {
  background: rgb(33 33 33 / 20%) !important;
}
.com-application-SideNavigation__wrapper {
  background-color: rgb(0 0 0 / 20%) !important;
  background-image: linear-gradient(270deg,transparent, #000) !important;
}
.com-tv-LinearFooter__button button {
  background-color: rgb(0 0 0 / 20%) !important;
}
.com-application-SideNavigation__wrapper:hover,
.com-tv-LinearFooter__button button:hover {
  background-color: rgb(0 0 0 / 80%) !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: rgb(33 33 33 / 50%) !important;
}
.com-question-QuestionContainerView .com-question-VoteContent {
  background-color: rgb(23 23 23 / 60%) !important;
}
.com-question-QuestionContainerView .com-question-VoteContent:hover,
.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: transparent !important;
}
.com-tv-CommentBlock__inner:hover,
.com-comment-TwitterSigninButton:hover,
.com-comment-CommentItem__inner:hover,
.com-archive-comment-ArchiveCommentItem:hover {
  background-color: rgb(0 0 0 / 30%) !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: rgb(255 255 255 / 20%) !important;
}
.com-o-CommentForm__can-post .com-o-CommentForm__opened-textarea:focus,
.com-comment-CommentTextarea__textarea:focus {
  background-color: rgb(255 255 255 / 80%) !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: rgb(0 0 0 / 20%) !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: rgb(221 170 0 / 50%) !important;
}
.com-a-Button--primary:hover:not([disabled]),
.com-comment-CommentSubmitButton:hover:not([disabled]) {
  background-color: #DDAA00 !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: rgb(0 0 0 / 20%) !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: rgb(0 0 0 / 30%);
  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: rgb(0 0 0 / 50%) !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: rgb(255 255 255 / 90%);
  border-radius: 4px;
  top: 0px;
  right: 0px;
  box-shadow: 0px 2px 4px rgb(0 0 0 / 50%);
  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: rgb(64 64 64 / 80%) !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, rgb(0 0 0 / 30%) 0px 220px, rgb(22 72 90 / 30%));
  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, rgb(0 0 0 / 70%) 0px 220px, rgb(22 72 90 / 70%));
}
.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;
}
.${sid}_NextProgramItem__thumbnail {
  flex-basis: auto;
  flex-grow: 0;
  flex-shrink: 0;
  height: 54px;
  position: relative;
  transition-duration: .5s;
  transition-property: width, height;
  transition-timing-function: cubic-bezier(.33, 1, .68, 1);
  width: 96px;
  .com-m-Thumbnail {
    height: 100%;
    overflow: hidden;
    position: relative;
    width: 100%;
  }
  .com-m-Thumbnail--rounded {
    border-radius: 4px;
  }
  .com-m-Thumbnail--loaded {
    background-color: #0b0b0b;
  }
  .com-m-Thumbnail__image {
    height: 100%;
    left: 0;
    object-fit: contain;
    position: absolute;
    top: 0;
    width: 100%;
  }
  .com-m-Thumbnail__border--with-faint-black, .com-m-Thumbnail__border--with-faint-white {
    box-sizing: border-box;
    display: block;
    height: 100%;
    left: 0;
    pointer-events: none;
    position: absolute;
    top: 0;
    width: 100%;
  }
  .com-m-Thumbnail__border--with-faint-white {
    border: 1px solid hsla(0, 0%, 100%, .12);
  }
  .com-tv-LinearChannelListItem__overlay {
    background-color: rgba(0, 0, 0, .5);
    border-radius: 4px;
    content: "";
    height: 100%;
    left: 0;
    position: absolute;
    top: 0;
    transition-duration: .5s;
    transition-property: opacity;
    transition-timing-function: cubic-bezier(.33, 1, .68, 1);
    width: 100%;
  }
  .com-tv-LinearChannelListItem__logo {
    left: 50%;
    position: absolute;
    top: 50%;
    transform: translate(-50%, -50%);
    transition-duration: .5s;
    transition-property: opacity;
    transition-timing-function: cubic-bezier(.33, 1, .68, 1);
  }
}
      `,
      updateCurrentChannelList = `
/*チャンネルリストに放送中の番組を0番目のNextアイテムとして表示する*/
.com-tv-LinearChannelListItem__inner:has(.${sid}_NextProgramItem__thumbnail:not([style="display: none;"])) .com-tv-LinearChannelListItem__thumbnail,
.com-tv-LinearChannelListItem__inner:has(.${sid}_NextProgramItem__details_0.${sid}_active) .com-tv-LinearChannelListItem__details {
  display: none !important;
}
      `,
      // 番組表
      timetableCompactHeader = `
/*番組表ページのヘッダーをコンパクトにする*/
.c-common-HeaderContainer-header,
.com-application-Header,
.com-pages-timetable-TimetableHeader {
  height: 48px !important;
}
.com-pages-timetable-TimetableHeader__title {
  font-size: 24px;
  line-height: 48px;
}
.com-pages-timetable-TimetableHeader {
  height: 44px !important;
  top: 44px !important;
}
.com-timetable-DesktopTimeTableWrapper__channel-content-header-wrapper {
  top: 48px !important;
}
.com-timetable-DesktopTimeTableWrapper__content-wrapper,
.com-timetable-TimeTableListTimeAxis {
  margin-top: 103px;
}
.com-application-Header:before {
  background-color: transparent !important;
}
.com-timetable-DesktopTimeTableWrapper__header-wrapper {
  height: 48px;
  left: 200px;
  padding: 0;
  position: fixed;
  top: 0;
  width: fit-content;
  z-index: 99;
}
.com-application-TimetableCalendarHeaderMenu__menu,
.com-application-TimetableChannelHeaderMenu__menu {
  top: 32px !important;
}
      `,
      timetableStickyProgram = `
/*番組表を縦にスクロールしてもその時間の番組を表示する*/
.com-timetable-TimetableItem__wrapper,
.com-timetable-TimetableItemHeader {
  background-color: inherit;
}
.com-timetable-TimetableItemHeader {
  position: sticky;
  top: 8px;
  z-index: 2;
}
.com-timetable-TimetableItem__thumbnail-wrapper {
  position: sticky;
  top: calc(13px + 5em);
  z-index: 1;
}
.com-timetable-TimetableItemDescription > .com-a-CollapsedText__container {
  position: sticky;
  top: calc(101px + 5em);
}
      `,
      timetableUninterestedChannel = `
/*番組表で興味のないチャンネルを右寄せ&薄く表示する*/
.${sid}_uninterestedChannel {
  opacity: 0.25;
  order: 9999;
}
.${sid}_uninterestedChannel:hover {
  opacity: 1;
}
      `,
      customTimetableAvoidSidePanel = `
/*カスタム番組表をサイドパネルに重ねない*/
body:has(.c-tv-NowOnAirContainer__side-panel[aria-hidden="false"]) #${sid}_CustomTimetable {
  max-width: calc(100vw - 128px - ${
    setting.sidePanelSize ? setting.sidePanelSizeNum : '320'
  }px);
}
      `,
      // ビデオ・見逃し視聴・ライブイベント
      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 rgb(255 165 0 / 30%), -2px 0px 1px rgb(255 165 0 / 30%), -2px 2px 1px rgb(255 165 0 / 30%),
     0px -2px 1px rgb(255 165 0 / 30%),  0px 2px 1px rgb(255 165 0 / 30%),
     2px -2px 1px rgb(255 165 0 / 30%),  2px 0px 1px rgb(255 165 0 / 30%),  2px 2px 1px rgb(255 165 0 / 30%) !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: rgb(128 83 0 / 30%) !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 rgb(0 192 0 / 60%), -2px 0px 1px rgb(0 192 0 / 60%), -2px 2px 1px rgb(0 192 0 / 60%),
      0px -2px 1px rgb(0 192 0 / 60%),  0px 2px 1px rgb(0 192 0 / 60%),
      2px -2px 1px rgb(0 192 0 / 60%),  2px 0px 1px rgb(0 192 0 / 60%),  2px 2px 1px rgb(0 192 0 / 60%) !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 rgb(192 96 192 / 60%), -2px 0px 1px rgb(192 96 192 / 60%), -2px 2px 1px rgb(192 96 192 / 60%),
     0px -2px 1px rgb(192 96 192 / 60%),  0px 2px 1px rgb(192 96 192 / 60%),
     2px -2px 1px rgb(192 96 192 / 60%),  2px 0px 1px rgb(192 96 192 / 60%),  2px 2px 1px rgb(192 96 192 / 60%) !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 rgb(255 0 0 / 80%), -2px 0px 1px rgb(255 0 0 / 80%), -2px 2px 1px rgb(255 0 0 / 80%),
     0px -2px 1px rgb(255 0 0 / 80%),  0px 2px 1px rgb(255 0 0 / 80%),
     2px -2px 1px rgb(255 0 0 / 80%),  2px 0px 1px rgb(255 0 0 / 80%),  2px 2px 1px rgb(255 0 0 / 80%) !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 rgb(224 224 0 / 80%), -2px 0px 1px rgb(224 224 0 / 80%), -2px 2px 1px rgb(224 224 0 / 80%),
     0px -2px 1px rgb(224 224 0 / 80%),  0px 2px 1px rgb(224 224 0 / 80%),
     2px -2px 1px rgb(224 224 0 / 80%),  2px 0px 1px rgb(224 224 0 / 80%),  2px 2px 1px rgb(224 224 0 / 80%) !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 === 'updateCurrentChannelList') {
      style.textContent = updateCurrentChannelList;
      // 番組表
    } else if (s === 'timetableCompactHeader') {
      style.textContent = timetableCompactHeader;
    } else if (s === 'timetableStickyProgram') {
      style.textContent = timetableStickyProgram;
    } else if (s === 'timetableUninterestedChannel') {
      style.textContent = timetableUninterestedChannel;
    } else if (s === 'customTimetableAvoidSidePanel') {
      style.textContent = customTimetableAvoidSidePanel;
      // ビデオ・見逃し視聴・ライブイベント
    } 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 applyTheme = () => {
    const themeModeMap = {
      0: 'light',
      1: 'light dark',
      2: 'light',
      3: 'dark',
    };
    const themeAttrMap = {
      0: 'default',
      1: 'auto',
      2: 'light',
      3: 'dark',
    };
    const mode = themeModeMap[setting.theme] || 'light';
    const attr = themeAttrMap[setting.theme] || 'default';
    document.documentElement.style.setProperty(`--${sid}-theme-mode`, mode);
    document.documentElement.setAttribute(
      `data-${sid.toLowerCase()}-theme`,
      attr,
    );
  };

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

  /**
   * 番組表で興味なしチャンネルを右寄せ&薄く表示する
   */
  const applyTimetableUninterest = () => {
    if (returnContentType() !== 'tt') return;
    if (
      data.uninterestedChannels.size === 0 ||
      !setting.timetableUninterestedChannel
    ) {
      // 興味なしチャンネルがない、または設定がオフの場合は念のため表示を戻す
      document
        .querySelectorAll(
          `${selector.timetableHeaderItem}, ${selector.timetableColumn}`,
        )
        .forEach((e) => {
          if (e instanceof HTMLElement) {
            e.classList.remove(`${sid}_uninterestedChannel`);
          }
        });
      return;
    }

    const headers = document.querySelectorAll(selector.timetableHeaderItem);
    const columns = document.querySelectorAll(selector.timetableColumn);

    headers.forEach((header, index) => {
      if (!(header instanceof HTMLElement)) return;
      const link = header.querySelector('a[href^="/timetable/channels/"]');
      if (link instanceof HTMLAnchorElement) {
        const channelId = link
          .getAttribute('href')
          ?.split('/')
          .pop()
          ?.split('?')[0];
        const isUninterested =
          channelId && data.uninterestedChannels.has(channelId);

        // ヘッダーの制御
        if (isUninterested) {
          header.classList.add(`${sid}_uninterestedChannel`);
        } else {
          header.classList.remove(`${sid}_uninterestedChannel`);
        }

        // 対応するインデックスのカラムも制御
        const column = columns[index];
        if (column instanceof HTMLElement) {
          if (isUninterested) {
            column.classList.add(`${sid}_uninterestedChannel`);
          } else {
            column.classList.remove(`${sid}_uninterestedChannel`);
          }
        }
      }
    });

    // チャンネル並び順の更新
    const order = Array.from(headers)
      .map((header) => {
        const link = header.querySelector('a[href^="/timetable/channels/"]');
        return link instanceof HTMLAnchorElement
          ? link.getAttribute('href')?.split('/').pop()
          : null;
      })
      .filter((id) => typeof id === 'string');
    if (order.length > 0) updateChannelOrder(/** @type {string[]} */ (order));
  };

  /**
   * 動画を構成している要素に変更があったとき
   */
  const changeElements = () => {
    const content = returnContentType();
    if (content === 'tv') {
      if (setting.closeSidePanel) checkSidePanel();
      // サイドパネルのリストが表示されている場合のみ次の番組情報を更新
      if (
        setting.nextPrograms &&
        document.querySelector(selector.tvChannelListItem)
      ) {
        applyTimetableUninterest();
      }
    } else if (/ts|vi/.test(content)) {
      closeNextProgramInfo();
    }
    hasCommentElement();
    updateCustomTimetableSideNav(setting.customTimetable);
    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);
          data.commentMouseEnter = false;
          document
            .querySelector(selector.sidePanel)
            ?.classList.remove(`${sid}_scrolling-paused`);
        }
      }
    } 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();
        observeTimetable();
      } else {
        reStyle('mouseoverNavigation', setting.mouseoverNavigation);
        if (data.timetableObserver) {
          data.timetableObserver.disconnect();
          data.timetableObserver = null;
        }
      }
      if (content === 'tv') {
        clearInterval(interval.changePageTitle);
        clearTimeout(interval.checkSwitchedProgramNext);
        interval.checkSwitchedProgramNext = 0;
        interval.changePageTitle = setTimeout(checkSwitchedProgram, 1000);
        if (document.getElementById(`${sid}_CustomTimetable`)) {
          renderCustomTimetable('initial');
        }
      }
    }
  };

  /**
   * 動画の画質を変更する
   * @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') {
              clearTimeout(interval.checkSwitchedProgramNext);
              interval.checkSwitchedProgramNext = 0;
              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 {HTMLDivElement} e2 カスタムデータ属性を付与する要素
   * @param {string} mes 正規化されたコメント本文
   * @param {string} u userID
   */
  const checkDuplicateCommentSpam = (e2, mes, u) => {
    if (!setting.hideDuplicateComment) return;

    const getThreshold = (level, length) => {
      const base = 5 - level;
      const t = length <= 15 ? base : base - 1;
      return Math.max(2, t);
    };

    let userData = data.spamData[u];
    if (!userData || userData.lastMessage !== mes) {
      const [spamLevel] = lsSpam[u] || [0];
      const threshold = getThreshold(spamLevel, mes.length);
      if (!userData) {
        data.spamData[u] = userData = {
          lastMessage: mes,
          consecutiveCount: 1,
          threshold: threshold,
        };
      } else {
        userData.lastMessage = mes;
        userData.consecutiveCount = 1;
        userData.threshold = threshold;
      }
    } else {
      userData.consecutiveCount += 1;
    }

    const threshold = userData.threshold;
    if (userData.consecutiveCount >= threshold) {
      e2.dataset[`${sid.toLowerCase()}Spam`] = '';
      e2.dataset[`${sid.toLowerCase()}Hidden`] = '';

      // 閾値の倍数(5, 10, 15... または 4, 8... など)に達した時にレベルアップ
      if (userData.consecutiveCount % threshold === 0) {
        const [spamLevel] = lsSpam[u] || [0];
        if (spamLevel < 3) {
          const mesHash = hashString(mes);
          const newLevel = spamLevel + 1;
          log(
            '連投コメント:',
            mes,
            'Hash:',
            mesHash,
            'ID:',
            u,
            'Level:',
            newLevel,
            'Count:',
            userData.consecutiveCount,
          );
          // データの鮮度を保つため一度削除してから再登録(FIFO用)
          delete lsSpam[u];
          lsSpam[u] = [newLevel];

          // 閾値を即座に更新
          userData.threshold = getThreshold(newLevel, mes.length);

          // 保存件数が1000件を超えたら古いものから削除
          const keys = Object.keys(lsSpam);
          if (keys.length > 1000) {
            delete lsSpam[keys[0]];
          }
          saveSpamSettings();
        }
      }
    }
  };

  /**
   * キーボードのキーを押したとき
   * @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.toLowerCase() === 'o' &&
      (!isInput || (isInput && e.altKey))
    ) {
      openSettings();
    } else if (
      setting.programInfo1 &&
      data.dataProviderRunning &&
      e.shiftKey &&
      e.key.toLowerCase() === 'p' &&
      (!isInput || (isInput && e.altKey))
    ) {
      toggleProgramInfo();
    } else if (
      data.dataProviderRunning &&
      setting.customTimetable &&
      e.shiftKey &&
      e.key.toLowerCase() === 't' &&
      (!isInput || (isInput && e.altKey))
    ) {
      if (document.getElementById(`${sid}_CustomTimetable`)) {
        closeCustomTimetable();
      } else {
        openCustomTimetable();
      }
    } else if (e.key === 'Escape') {
      if (
        e.target instanceof HTMLInputElement &&
        e.target.matches(selector.searchInput)
      ) {
        e.target.blur();
      }
      const searchResults = document.getElementById(`${sid}_SearchResults`);
      if (
        searchResults &&
        !searchResults.classList.contains(`${sid}_SearchResults-hidden`)
      ) {
        searchResults.classList.add(`${sid}_SearchResults-hidden`);
      } else if (!isInput) {
        const searchResultsHelp = document.getElementById(
            `${sid}_SearchResults-help`,
          ),
          customTimetableHelpPanel = document.getElementById(
            `${sid}_CustomTimetable-help-panel`,
          ),
          customTimetableLayer = document.getElementById(
            `${sid}_CustomTimetable`,
          ),
          customTimetableDetail = document.getElementById(
            `${sid}_CustomTimetable-detail`,
          );
        if (
          searchResultsHelp &&
          searchResultsHelp.classList.contains('is-visible')
        ) {
          searchResultsHelp.classList.remove('is-visible');
        } else if (
          customTimetableHelpPanel &&
          !customTimetableHelpPanel.classList.contains(
            `${sid}_CustomTimetable-help-panel-hidden`,
          )
        ) {
          customTimetableHelpPanel.classList.add(
            `${sid}_CustomTimetable-help-panel-hidden`,
          );
        } else if (customTimetableLayer) {
          if (
            customTimetableDetail &&
            !customTimetableDetail.classList.contains(
              `${sid}_CustomTimetable-detail-hidden`,
            )
          ) {
            customTimetableDetail.classList.add(
              `${sid}_CustomTimetable-detail-hidden`,
            );
          } else {
            closeCustomTimetable();
          }
        } else {
          closeCommentReportForm();
        }
      }
    } 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.toLowerCase() === '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.toLowerCase() === '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();
      }
    }
  };

  /**
   * チャンネルリストのDOM変更(React再描画等)を検知した際の処理
   */
  const checkLinearChannelListChange = () => {
    log('checkLinearChannelListChange');
    debouncedShowNextPrograms();
  };

  /**
   * マウスカーソルをコメントリストに重ねたとき
   * @param {MouseEvent} e
   */
  const checkMouseEnter = (e) => {
    if (e.currentTarget === e.target) {
      data.commentMouseEnter = true;
      document
        .querySelector(selector.sidePanel)
        ?.classList.add(`${sid}_scrolling-paused`);
    }
  };

  /**
   * マウスカーソルをコメントリストから外したとき
   * @param {MouseEvent} e
   */
  const checkMouseLeave = (e) => {
    if (e.currentTarget === e.target) {
      data.commentMouseEnter = false;
      document
        .querySelector(selector.sidePanel)
        ?.classList.remove(`${sid}_scrolling-paused`);
    }
  };

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

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

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

    const currentHistory = lsState.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 (lsState.discoveryNotified[id]) {
                delete lsState.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',
                  );
                  lsState.channelId[id] = Math.floor(Date.now() / 1000);
                  if (lsState.discoveryNotified[id]) {
                    delete lsState.discoveryNotified[id];
                  }
                  saveState();
                  delete interval[timerKey];
                }, delayMs);
              }

              // 過去の番組がない(完全に新しい)かつ、開始まで1時間以上ある場合は発見通知
              if (!isFirstRun && !lsState.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 (lsState.discoveryNotified[id]) {
              delete lsState.discoveryNotified[id];
            }
          }
        }),
      );

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

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

      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) {
      lsState.channelId = currentHistory;
      saveState();
    }
  };

  /**
   * 新規コメントを読み込んだとき
   */
  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();
      data.spamData = Object.create(null);
      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);
    clearTimeout(interval.checkSwitchedProgramNext);
    interval.checkSwitchedProgramNext = 0;
    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);
          clearTimeout(interval.checkSwitchedProgramNext);
          interval.checkSwitchedProgramNext = 0;
          log('checkSwitchedProgram: 番組切り替えを検知', n);
          if (
            ta instanceof HTMLTextAreaElement &&
            !ta.placeholder.includes(' / ') &&
            !ta.placeholder.includes('視聴数')
          ) {
            ta.placeholder = 'コメントを入力';
          }
          if (setting.programInfo1 || setting.programInfo2) {
            getProgramInfo(id);
          }
        }
        if (!interval.checkSwitchedProgramNext) {
          const endAt = screen
            ? getCommentProps(screen, 'programEndAt', 'tv')
            : null;
          if (typeof endAt === 'number') {
            const delay = normalizeTimestamp(endAt) - Date.now() + 1000;
            if (delay > 0) {
              interval.checkSwitchedProgramNext = setTimeout(
                checkSwitchedProgram,
                delay,
              );
              log(
                'checkSwitchedProgram: 次回の切り替え判定を予約',
                new Date(normalizeTimestamp(endAt) + 1000).toLocaleString(),
              );
            }
          }
        }
      } 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 = () => {
    // データ移行処理 (ls -> lsState)
    let migrated = false;
    ['channelId', 'channelOrder', 'discoveryNotified', 'version'].forEach(
      (key) => {
        if (key in ls) {
          lsState[key] = ls[key];
          delete ls[key];
          migrated = true;
        }
      },
    );
    // lsWord.warningRe -> lsState.warningRe
    if (lsWord && 'warningRe' in lsWord) {
      lsState.warningRe = lsWord.warningRe;
      delete lsWord.warningRe;
      migrated = true;
    }

    if (migrated) {
      log('Migrated data to lsState');
      saveSettings();
      saveWordSettings();
      saveState();
    }

    if ('version' in lsState) {
      if (lsState.version < 9) {
        if ('pageKey' in ls) {
          delete ls.pageKey;
          log('delete ls.pageKey');
        }
      }
    }
    lsState.version = data.version;
    saveState();
  };

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

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

  /**
   * ブロックアイコンをクリックして表示した報告フォームをすべて閉じる
   */
  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);
      }
      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 closeCustomTimetable = () => {
    // タイマーを解除
    window.clearTimeout(interval.customTimetableUpdate);
    document.getElementById(`${sid}_CustomTimetable`)?.remove();
  };

  /**
   * 通知(トースト)を閉じる処理
   */
  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;
  };

  /**
   * カスタム番組表のコンテナを作成する
   * @returns {HTMLElement}
   */
  const createCustomTimetableLayer = () => {
    let container = document.getElementById(`${sid}_CustomTimetable`);
    if (container) return container;

    container = document.createElement('div');
    container.id = `${sid}_CustomTimetable`;

    // 設定されているチャンネルグループから有効なグループ番号を一意に取得
    const validGroupIds = new Set(
      Object.values(setting.channelGroups)
        .map(Number)
        .filter((n) => n >= 1 && n <= 15),
    );
    // 検索キーワードが設定されているグループも有効にする
    for (let i = 1; i <= 15; i++) {
      const gData = setting.groupData[String(i)];
      if (
        gData &&
        gData.searchWords &&
        gData.searchWords.some((w) => w && w.trim())
      ) {
        validGroupIds.add(i);
      }
    }
    // 未設定の場合は最低1を表示(初期状態フラグも保持)
    const isInitialState = validGroupIds.size === 0;
    if (isInitialState) validGroupIds.add(1);

    // ヘッダーとチャンネルグループ用のセレクトボックスの作成
    const optionsHtml = Array.from({ length: 15 }, (_, i) => `グループ${i + 1}`)
      .map((defaultName, index) => {
        if (!validGroupIds.has(index + 1)) return '';
        // 初期状態(グループ未設定)のときはグループ番号なしの「グループ」と表示する
        const gName =
          isInitialState && index === 0
            ? 'グループ'
            : setting.groupData[String(index + 1)]?.name || defaultName;
        return `<option value="${index + 1}">${escapeHTML(gName)}</option>`;
      })
      .join('');

    container.innerHTML = `
      <div id="${sid}_CustomTimetable-header">
        <span id="${sid}_CustomTimetable-title">番組表</span>
        <span id="${sid}_CustomTimetable-group">
          <button id="${sid}_CustomTimetable-prevGroup" title="前のグループ (Alt+Wheel / ←)"><</button>
          <select id="${sid}_CustomTimetable-groupSelect" title="表示するグループを選択">
            ${optionsHtml}
          </select>
          <button id="${sid}_CustomTimetable-nextGroup" title="次のグループ (Alt+Wheel / →)">></button>
        </span>
        <span id="${sid}_CustomTimetable-period">
          <button id="${sid}_CustomTimetable-period-prev" title="前の期間へ"><</button>
          <select id="${sid}_CustomTimetable-period-select" title="表示期間を選択">
            <option value="-3">-</option>
            <option value="-2">-</option>
            <option value="-1">-</option>
            <option value="0" selected>表示期間</option>
            <option value="1">-</option>
            <option value="2">-</option>
            <option value="3">-</option>
          </select>
          <button id="${sid}_CustomTimetable-period-next" title="次の期間へ">></button>
        </span>
        <button id="${sid}_CustomTimetable-help" title="ヘルプ(操作方法を表示)">?</button>
        <button id="${sid}_CustomTimetable-close" title="閉じる (Esc)">×</button>
      </div>
      <div id="${sid}_CustomTimetable-content"></div>
      <div id="${sid}_CustomTimetable-detail" class="${sid}_CustomTimetable-detail-hidden">
        <div id="${sid}_CustomTimetable-detail-header">
          <span id="${sid}_CustomTimetable-detail-title">番組詳細</span>
          <button id="${sid}_CustomTimetable-detail-close" title="閉じる">×</button>
        </div>
        <div id="${sid}_CustomTimetable-detail-body"></div>
      </div>
      <div id="${sid}_CustomTimetable-help-panel" class="${sid}_CustomTimetable-help-panel-hidden">
        <div id="${sid}_CustomTimetable-help-panel-header">
          <span id="${sid}_CustomTimetable-help-panel-title">カスタム番組表について</span>
          <button id="${sid}_CustomTimetable-help-panel-close" title="閉じる">×</button>
        </div>
        <div id="${sid}_CustomTimetable-help-panel-body"></div>
      </div>
    `;

    document.body.appendChild(container);

    // カスタム番組表の共通イベントリスナー(イベント委譲 - 一度だけ登録)
    const content = document.getElementById(`${sid}_CustomTimetable-content`);
    if (content) {
      // 番組クリックでの詳細表示
      content.addEventListener('click', (e) => {
        const target = e.target;
        if (!(target instanceof HTMLElement)) return;
        const programEl = target.closest(`.${sid}_CustomTimetable-program`);
        if (programEl instanceof HTMLElement) {
          const slotId = programEl.dataset.slotId;
          const fullSlots = /** @type {any} */ (content)._fullSlotsToRender;
          const slot = fullSlots
            ? fullSlots.find((s) => s.id === slotId)
            : null;
          if (slot) {
            const detailContainer = document.getElementById(
              `${sid}_CustomTimetable-detail`,
            );
            const detailBody = document.getElementById(
              `${sid}_CustomTimetable-detail-body`,
            );
            if (detailContainer && detailBody) {
              createProgramInfo(slot, detailBody);
              detailContainer.classList.remove(
                `${sid}_CustomTimetable-detail-hidden`,
              );
            }
          }
        }
      });

      // スクロール追従(日付表示の更新)
      content.addEventListener('scroll', () => {
        const c = /** @type {any} */ (content);
        if (c._getUnixFromY && c._DAY_NAMES) {
          window.requestAnimationFrame(() => {
            const scrollUnix = c._getUnixFromY(content.scrollTop);
            const d = new Date(scrollUnix * 1000);
            const topleftDate = document.getElementById(
              `${sid}_CustomTimetable-topdate`,
            );
            const topleftDay = document.getElementById(
              `${sid}_CustomTimetable-topday`,
            );
            if (topleftDate && topleftDay) {
              topleftDate.textContent = `${d.getMonth() + 1}/${d.getDate()}`;
              topleftDay.textContent = `(${c._DAY_NAMES[d.getDay()]})`;
            }
          });
        }
      });
    }

    // ヘルプ表示
    document
      .getElementById(`${sid}_CustomTimetable-help`)
      ?.addEventListener('click', () => {
        const helpBody = document.getElementById(
          `${sid}_CustomTimetable-help-panel-body`,
        );
        const helpPanel = document.getElementById(
          `${sid}_CustomTimetable-help-panel`,
        );
        if (helpBody && helpPanel) {
          helpBody.innerHTML = `
<p><b>【基本操作】</b></p>
<ul>
  <li>マウスホイール:上下スクロール</li>
  <li>Alt + マウスホイール:チャンネルグループ切り替え</li>
  <li>キーボード ← / →:チャンネルグループ切り替え</li>
  <li>Escキー:番組表を閉じる</li>
</ul>
<br>
<p><b>【機能】</b></p>
<ul>
  <li>「< >」ボタン:グループおよび表示期間の切り替え</li>
  <li>グループセレクトボックス:表示したいグループを選択</li>
  <li>期間セレクトボックス:表示したい期間を選択</li>
  <li>番組をクリック:番組詳細を表示</li>
</ul>
          `.trim();
          helpPanel.classList.remove(
            `${sid}_CustomTimetable-help-panel-hidden`,
          );
        }
      });

    const setupPanelCloseHandler = (buttonId, panelId) => {
      document.getElementById(buttonId)?.addEventListener('click', () => {
        const panel = document.getElementById(panelId);
        if (panel) {
          panel.classList.add(`${panelId}-hidden`);
          // フォーカスを本体に戻してキーボード操作を維持
          container.focus();
        }
      });
    };

    // ヘルプパネルの閉じるボタン
    setupPanelCloseHandler(
      `${sid}_CustomTimetable-help-panel-close`,
      `${sid}_CustomTimetable-help-panel`,
    );

    // 詳細パネルの閉じるボタン
    setupPanelCloseHandler(
      `${sid}_CustomTimetable-detail-close`,
      `${sid}_CustomTimetable-detail`,
    );

    // キーボード操作を受け付けるためにフォーカス可能にする(アウトラインは非表示)
    container.tabIndex = -1;
    container.style.outline = 'none';

    // 閉じるイベント
    document
      .getElementById(`${sid}_CustomTimetable-close`)
      ?.addEventListener('click', closeCustomTimetable);

    // グループ変更イベントとコントロール
    const groupSelect = document.getElementById(
      `${sid}_CustomTimetable-groupSelect`,
    );
    groupSelect?.addEventListener('change', () => {
      renderCustomTimetable('initial');
    });

    // 前後のグループ切り替えボタンイベント
    const changeGroup = (dir) => {
      if (groupSelect instanceof HTMLSelectElement) {
        const numOptions = groupSelect.options.length;
        const newIndex =
          (groupSelect.selectedIndex + dir + numOptions) % numOptions;
        if (groupSelect.selectedIndex !== newIndex) {
          groupSelect.selectedIndex = newIndex;
          renderCustomTimetable('initial');
        }
      }
    };

    document
      .getElementById(`${sid}_CustomTimetable-prevGroup`)
      ?.addEventListener('click', () => changeGroup(-1));
    document
      .getElementById(`${sid}_CustomTimetable-nextGroup`)
      ?.addEventListener('click', () => changeGroup(1));

    // Alt+Wheel および 左右キーでの操作イベント
    container.addEventListener(
      'wheel',
      (e) => {
        if (e.altKey) {
          e.preventDefault();
          changeGroup(e.deltaY > 0 ? 1 : -1);
        }
      },
      { passive: false },
    );

    container.addEventListener('keydown', (e) => {
      // セレクトボックス自体にフォーカスがある時は標準の矢印操作を優先
      if (document.activeElement === groupSelect) return;
      const keyMap = {
        ArrowLeft: -1,
        ArrowRight: 1,
      };
      if (Object.hasOwn(keyMap, e.key)) {
        e.preventDefault();
        changeGroup(keyMap[e.key]);
      }
    });

    // 表示期間切り替えボタンのイベント
    // periodOffset: -3〜+3 の7段階
    const changePeriod = (newOffset) => {
      const clamped = Math.max(-3, Math.min(3, newOffset));
      data.customTimetablePeriodOffset = clamped;
      renderCustomTimetable('period');
    };
    const buttonConfigs = [
      { id: 'prev', value: -1, relative: true },
      { id: 'next', value: 1, relative: true },
    ];
    buttonConfigs.forEach(({ id, value, relative }) => {
      document
        .getElementById(`${sid}_CustomTimetable-period-${id}`)
        ?.addEventListener('click', () => {
          const newOffset = relative
            ? data.customTimetablePeriodOffset + value
            : value;
          changePeriod(newOffset);
        });
    });

    // 期間セレクトボックスのイベント
    document
      .getElementById(`${sid}_CustomTimetable-period-select`)
      ?.addEventListener('change', (e) => {
        if (e.target instanceof HTMLSelectElement) {
          changePeriod(parseInt(e.target.value, 10));
        }
      });

    return container;
  };

  /**
   * 通知用の番組オブジェクトを作成
   * @param {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>
      <br>
      <label title="設定欄や番組情報などのカラーテーマを切り替えます。">
        カラーテーマ:
        <select id="${sid}_Settings-theme">
          <option value="0">デフォルト</option>
          <option value="1">デバイスの設定に従う</option>
          <option value="2">ライト</option>
          <option value="3">ダーク</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;初期値: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="他のチャンネルが次の番組に切り替わったとき、チャンネルリストの番組情報を自動更新します。">
        <input id="${sid}_Settings-updateCurrentChannelList" type="checkbox">
        チャンネルリストの番組情報を自動更新する
      </label>
      <br>
      <label title="検索欄から番組を検索して、検索結果をページ遷移せずに表示します。Shift+Enterキーを押したときは通常通りの検索結果ページを開きます。&#13;&#10;詳しい使い方は検索結果の右上に表示される「?」をクリックしてください。">
        <input id="${sid}_Settings-searchProgram" type="checkbox">
        番組の検索結果をページ遷移せずに表示する
      </label>
      <br>
    </fieldset>
  </div>
  <input id="${sid}_Settings-Tab-Timetable" type="radio" name="Tab" class="${sid}_Settings-tab-switch">
  <label class="${sid}_Settings-tab-label" for="${sid}_Settings-Tab-Timetable">番組表</label>
  <div id="${sid}_Settings-Timetable" class="${sid}_Settings-tab-content">
    <fieldset>
      <legend>番組表</legend>
      <label title="番組表ページのヘッダーを省スペース化して、番組表を広く表示します。">
        <input id="${sid}_Settings-timetableCompactHeader" type="checkbox">
        ヘッダーをコンパクトにする
      </label>
      <br>
      <label title="放送時間が長い番組でもスクロール位置に関わらず番組情報を常に表示します。">
        <input id="${sid}_Settings-timetableStickyProgram" type="checkbox">
        表示している時間の番組情報を常に表示する
      </label>
      <br>
      <label title="チャンネル設定で「興味なし」に設定したチャンネルを番組表ページで右寄せ&薄く表示します。">
        <input id="${sid}_Settings-timetableUninterestedChannel" type="checkbox">
        興味なしチャンネルを右寄せ&薄く表示する
      </label>
    </fieldset>
    <fieldset${data.dataProviderRunning ? '' : ' disabled'}>
      <legend>カスタム番組表</legend>
      <label title="サイドナビゲーションのカスタム番組表をクリック、もしくはShift+Tキー(入力欄にフォーカスしているときはAlt+Shift+Tキー)で、独自のカスタム番組表をページ遷移せずに表示します。">
        <input id="${sid}_Settings-customTimetable" type="checkbox">
        カスタム番組表を表示する
      </label>
      <br>
      <label title="右側のサイドパネルが表示されているとき、サイドパネルに重ならないようにカスタム番組表の横幅を縮めます。">
        <input id="${sid}_Settings-customTimetableAvoidSidePanel" type="checkbox">
        サイドパネルに重ならないようにする
      </label>
      <br>
      <label title="カスタム番組表を開いたときの表示期間を設定します。デフォルトは1日前から1日後までです。">
        表示期間:
        <select id="${sid}_Settings-timetableDaysBefore">
          <option value="0">今日</option>
          <option value="1">1日前</option>
          <option value="2">2日前</option>
          <option value="3">3日前</option>
        </select>
        から
        <select id="${sid}_Settings-timetableDaysAfter">
          <option value="0">今日</option>
          <option value="1">1日後</option>
          <option value="2">2日後</option>
          <option value="3">3日後</option>
        </select>
        まで
      </label>
    </fieldset>
    <fieldset>
      <legend>グループ・チャンネル</legend>
      <details>
        <summary class="${sid}_Settings-summary-help">[説明]</summary>
        <p>カスタム番組表に表示する番組やチャンネルを設定します。</p>
        <p>グループ設定ではグループごとに検索キーワードを設定できます。グループ名は最大10文字で変更でき、キーワードはグループごとに最大10件登録できます。</p>
        <details class="${sid}_Settings-details-nest">
          <summary>[詳細] 検索キーワードについて</summary>
          <p>検索キーワードには番組名に一致するキーワードのほかに、下記の特殊キーワードやチャンネル名・チャンネルIDを指定でき、一致したチャンネルのみが表示されます。番組名にキーワードの一部でも一致したチャンネルについては、キーワードに該当しない番組は薄く表示されます。</p>
          <p>スペースで区切ると、すべての条件に一致する番組を検索します(AND検索)。<br><code>OR</code> または <code>|</code> で区切ると、いずれかの条件に一致する番組を検索します(OR検索)。<br><code>"</code> 引用符 <code>"</code> で囲むと、特殊キーワードなどを無視して検索します(フレーズ検索)。</p>
          <p><b>特殊キーワード</b></p>
          <ul>
            <li><code>now</code>: 現在放送中の番組</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>watching</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>
          <ul>
            <li><code>[abema-anime]</code>: チャンネルIDで絞り込み(部分一致)</li>
            <li><code>[ABEMA アニメチャンネル]</code>: チャンネル名で絞り込み(部分一致)</li>
            <li><code>-キーワード</code>: 検索結果からこのキーワードを含む番組を除外</li>
          </ul>
        </details>
        <p>チャンネル設定ではチャンネルをグループに割り当てることで、グループごとに任意のチャンネルを表示できます。「興味なし」に設定したチャンネルは、カスタム番組表ページでは表示されません。</p>
        <p>「グループのキーワード設定」と「チャンネル設定」は、片方のみ、または両方を指定できます。</p>
      </details>
      <details id="${sid}_Settings-group">
        <summary id="${sid}_Settings-groupSummary" title="グループは最大15個まで登録でき、グループ名は最大10文字で変更できます。&#13;&#10;検索キーワードはグループごとに最大10件登録でき、キーワードに一致した番組やチャンネルをそのグループで表示します。">グループ<span class="${sid}_Settings-summary-sub">(クリックで開閉)</span></summary>
        <fieldset${data.dataProviderRunning ? '' : ' disabled'}>
          <legend>
          <select id="${sid}_Settings-groupList"></select>
          </legend>
        <label title="グループ名を変更します(最大10文字)。">
          <span class="${sid}_Settings-group_label">グループ名:</span>
          <input type="text" id="${sid}_Settings-groupName" maxlength="10">
        </label>
        <br>
        <label title="検索キーワードに一致した番組をカスタム番組表に表示します。検索キーワードは最大10件登録できます。&#13;&#10;特殊キーワード・チャンネル名・チャンネルIDでの絞り込みもできます。">
          <span class="${sid}_Settings-group_label">キーワード:</span>
          <select id="${sid}_Settings-groupSearchList"></select>
          <input type="text" id="${sid}_Settings-groupSearchWord">
        </label>
      </fieldset>
      </details>
      <details id="${sid}_Settings-channel">
        <summary id="${sid}_Settings-channelSummary" title="チャンネルをいずれかのグループに割り当てることができます。&#13;&#10;また、「興味なし」に設定したチャンネルは、番組表ページでは右寄せ&薄く表示し、カスタム番組表には表示されません。">チャンネル<span class="${sid}_Settings-summary-sub">(クリックで開閉)</span></summary>
        <ul id="${sid}_Settings-channelList">
          <!-- チャンネル一覧 -->
        </ul>
      </details>
    </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>
      <label title="同じユーザーが同じ内容のコメントを連続投稿したとき、その連投コメントを自動的に非表示にします。&#13;&#10;フレーズの繰り返しなどで内容がわずかに異なる場合でも連投と判定されることがあります。&#13;&#10;判定を行う連投回数は、コメントの長さや非表示にした回数によって変動します。">
        <input id="${sid}_Settings-hideDuplicateComment" 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 class="${sid}_Settings-summary-help">[説明]</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 class="${sid}_Settings-summary-help">[説明]</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 class="${sid}_Settings-summary-help">[説明]</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`),
          uccl = document.getElementById(
            `${sid}_Settings-updateCurrentChannelList`,
          );
        if (np instanceof HTMLInputElement && npn instanceof HTMLInputElement) {
          npn.disabled = !np.checked;
        }
        if (
          np instanceof HTMLInputElement &&
          uccl instanceof HTMLInputElement
        ) {
          uccl.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);

    // グループ設定UIの処理
    const eGroupList = document.getElementById(`${sid}_Settings-groupList`);
    const eGroupName = document.getElementById(`${sid}_Settings-groupName`);
    const eGroupSearchList = document.getElementById(
      `${sid}_Settings-groupSearchList`,
    );
    const eGroupSearchWord = document.getElementById(
      `${sid}_Settings-groupSearchWord`,
    );

    // オプションを動的に生成
    if (eGroupList instanceof HTMLSelectElement) {
      for (let i = 1; i <= 15; i++) {
        const opt = document.createElement('option');
        opt.value = String(i);
        opt.textContent = `グループ${i}`;
        eGroupList.appendChild(opt);
      }
    }
    if (eGroupSearchList instanceof HTMLSelectElement) {
      for (let i = 1; i <= 10; i++) {
        const opt = document.createElement('option');
        opt.value = String(i);
        opt.textContent = String(i);
        eGroupSearchList.appendChild(opt);
      }
    }

    const refreshGroupFields = () => {
      if (
        eGroupList instanceof HTMLSelectElement &&
        eGroupName instanceof HTMLInputElement &&
        eGroupSearchList instanceof HTMLSelectElement &&
        eGroupSearchWord instanceof HTMLInputElement
      ) {
        const id = eGroupList.value;
        const index = parseInt(eGroupSearchList.value, 10) - 1;
        const group = tempGroupData[id] || {
          name: `グループ${id}`,
          searchWords: [],
        };
        eGroupName.value = group.name || `グループ${id}`;
        eGroupSearchWord.value = group.searchWords[index] || '';
      }
    };

    const updateGroupSettingsUI = () => {
      tempGroupData = JSON.parse(JSON.stringify(setting.groupData));
      if (eGroupList instanceof HTMLSelectElement) {
        // セレクトボックス自体の表示名称を更新
        Array.from(eGroupList.options).forEach((opt) => {
          const groupId = opt.value;
          const gName = setting.groupData[groupId]?.name;
          if (gName) {
            opt.textContent = gName;
          } else {
            opt.textContent = `グループ${groupId}`;
          }
        });
      }
      refreshGroupFields();
    };

    eGroupName?.addEventListener('input', syncGroupSettingsFromUI);
    eGroupSearchWord?.addEventListener('input', syncGroupSettingsFromUI);

    const handleGroupSelectChange = () => {
      refreshGroupFields();
    };

    eGroupList?.addEventListener('change', handleGroupSelectChange);
    eGroupSearchList?.addEventListener('change', handleGroupSelectChange);

    updateGroupSettingsUI();

    // タブ切り替え時に告知の表示状態を更新するリスナー
    const tabSwitches = document.querySelectorAll(
      `.${sid}_Settings-tab-switch`,
    );
    tabSwitches.forEach((input) => {
      input.addEventListener('change', updateFooterNoticeVisibility);
    });
  };

  /**
   * showNextPrograms をデバウンスして実行する
   */
  const debouncedShowNextPrograms = () => {
    clearTimeout(interval.debounceShowNextPrograms);
    interval.debounceShowNextPrograms = setTimeout(() => {
      showNextPrograms();
    }, 100);
  };

  /**
   * データベースを削除する
   */
  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?.broadcastSlot?.id) {
            return e[key].return.pendingProps.broadcastSlot.id;
          }
        }
        if (k === 'programEndAt' && c === 'tv') {
          if (e[key].return?.pendingProps?.broadcastSlot?.endAt) {
            return e[key].return.pendingProps.broadcastSlot.endAt;
          }
        }
        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,
            displayProgramId: s.displayProgramId,
            displayImageUpdatedAt: s.displayImageUpdatedAt,
          }));

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

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

      if (setting.nextPrograms) debouncedShowNextPrograms();

      // 自動更新予約
      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/';
      }
    }
  };

  /**
   * djb2アルゴリズムによる軽量ハッシュ関数
   * @param {string} str ハッシュ化する文字列
   * @returns {number} 符号なし32ビット整数のハッシュ値
   */
  const hashString = (str) => {
    /* eslint-disable no-bitwise */
    let hash = 5381;
    for (let i = 0; i < str.length; i++) {
      hash = (hash << 5) + hash + str.charCodeAt(i);
      hash |= 0; // 32ビット整数に変換
    }
    return hash >>> 0; // 符号なし32ビット整数に変換
    /* eslint-enable no-bitwise */
  };

  /**
   * 必要ならコメントに色を付ける
   * @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 (typeof u !== 'string' || !m) return;

    let mes = m.trim();

    // 例外処理:同じ文字だけの繰り返し(例:「www」や「!!!」)の場合は圧縮しない
    // 文字列がすべて最初の文字と同じ文字で構成されているかどうかを判定します
    const isSingleCharRepeat =
      mes.length > 0 && [...mes].every((c, _, a) => c === a[0]);

    if (!isSingleCharRepeat) {
      // 2文字以上のパターンの繰り返しを圧縮(例:「暑い暑い」→「暑い」)
      mes = mes.replace(/(.{2,}?)\1+/gu, '$1');
    }

    mes = mes.replace(/(.)\1{3,}$/u, '$1$1$1');

    // 連続コメントの非表示判定
    checkDuplicateCommentSpam(e2, mes, u);

    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) {
            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);
  };

  /**
   * カスタム番組表項目のクリックハンドラ
   * @param {Event} event
   */
  const handleCustomTimetableSideNavClick = (event) => {
    event.preventDefault();
    if (document.getElementById(`${sid}_CustomTimetable`)) {
      closeCustomTimetable();
    } else {
      openCustomTimetable();
    }
  };

  /**
   * 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 hasLinearChannelList = () => {
    if (!setting.updateCurrentChannelList) return;
    clearInterval(interval.linearchannellist);
    interval.linearchannellist = setInterval(() => {
      const list = document.querySelector(selector.tvChannelList);
      if (list) {
        if (!data.dataProviderRunning) return; // データがなければ何もしないが監視は続ける
        if (!list.classList.contains(`${sid}_LinearChannelList`)) {
          log('hasLinearChannelList');
          list.classList.add(`${sid}_LinearChannelList`);
          // リストそのものと、その中身(子要素の追加削除)を監視する
          observerL.observe(list, { childList: true, subtree: true });
          // 出現した瞬間に一度実行する
          debouncedShowNextPrograms();
        }
      } else {
        // リストが消えたらクラスをリセットして次回の出現に備える
        const oldEntries = document.querySelectorAll(
          `.${sid}_LinearChannelList`,
        );
        oldEntries.forEach((el) =>
          el.classList.remove(`${sid}_LinearChannelList`),
        );
      }
    }, 1000);
  };

  /**
   * ページを開いたときに実行
   */
  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);
    });

    // 他のタブで設定が変更されたら、メモリ上の設定を最新化する
    addEventListener('storage', (e) => {
      // このスクリプトに関係のないキーや、値が空の場合は無視する
      if (
        !e.key ||
        !e.newValue ||
        ![sid, stateSid, `${sid}-Word`, `${sid}-Id`].includes(e.key)
      ) {
        return;
      }

      try {
        const updateObject = (target, source) => {
          Object.keys(target).forEach((key) => delete target[key]);
          Object.assign(target, source);
        };

        const newData = JSON.parse(e.newValue);
        if (e.key === sid) {
          // データの同期(副作用検知のためlsはここでは更新しない)
          Object.keys(newData).forEach((key) => {
            if (key in setting) {
              setting[key] = newData[key];
            }
          });
          // UI/副作用の反映
          loadSettings();
          saveSettings(true);

          // グループ設定の一時バッファも同期
          if (newData.groupData) {
            tempGroupData = JSON.parse(JSON.stringify(newData.groupData));
          }
          // 作業用データ(data)も同期してバックグラウンド処理による巻き戻しを防ぐ
          if (newData.channelOrder) data.channelOrder = newData.channelOrder;
          if (newData.channelGroups) {
            data.uninterestedChannels = new Set(
              Object.entries(newData.channelGroups)
                .filter(([, group]) => group === 16)
                .map(([id]) => id),
            );
          }
        } else if (e.key === stateSid) {
          updateObject(lsState, newData);
          // data の関連プロパティを更新
          if (newData.channelOrder) data.channelOrder = newData.channelOrder;
          if (newData.warningRe) data.ngWordWarningRe = [...newData.warningRe];
        } else if (e.key === `${sid}-Word`) {
          if (newData.ngWord !== undefined) {
            setting.ngWord = newData.ngWord;
            saveSettings(true); // NGワード更新に伴う副作用を実行(内部で lsWord も更新)
            loadSettings(); // UIを更新
          }
        } else if (e.key === `${sid}-Id`) {
          if (newData.ngId) {
            setting.ngId = newData.ngId;
            saveSettings(true); // NG ID更新に伴う副作用を実行(内部で lsId も更新)
            loadSettings(); // UIを更新
          }
        }
      } catch (err) {
        log(`${sid}: Failed to sync storage for ${e.key}`, err, 'error');
      }
    });

    initDataProviderDatabase();
    hasVideoElement();
    hasNotification();
    hasLinearChannelList();
    setTimeout(getNextPrograms, 1000);
    setTimeout(startFirstObserve, 1000);
    changePageTitle();
  };

  /**
   * 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 (lsState.lastCleanupDay) {
        delete lsState.lastCleanupDay;
        saveState();
      }
    } else {
      log('Dexie.js failed to load', 'error');
    }
  };

  /**
   * ページを開いたときに必要な分だけスタイルを追加
   */
  const initStyle = () => {
    addStyle('init');
    // 全般
    if (setting.reduceCommentSpace) addStyle('reduceCommentSpace');
    if (setting.mouseoverNavigation && returnContentType() !== 'tt') {
      addStyle('mouseoverNavigation');
    }
    if (setting.videoResolution) addStyle('videoResolution');
    if (setting.headerPosition && !/tv|tt/.test(returnContentType())) {
      addStyle('headerPosition');
    }
    if (setting.semiTransparent) addStyle('semiTransparent');
    if (setting.smallFontSize) addStyle('smallFontSize');
    if (setting.overlapSidePanel) addStyle('overlapSidePanel');
    if (setting.sidePanelSize) addStyle('sidePanelSize');
    if (setting.hiddenIdAndPlan) addStyle('hiddenIdAndPlan');
    if (setting.notifyNewChannel) addStyle('notifyNewChannel');
    // テレビ
    if (setting.sidePanelCloseButton) addStyle('sidePanelCloseButton');
    if (setting.showProgramDetail) addStyle('showProgramDetail');
    if (setting.hiddenButtonText) addStyle('hiddenButtonText');
    if (setting.nextPrograms) addStyle('nextPrograms');
    if (setting.updateCurrentChannelList) addStyle('updateCurrentChannelList');
    // 番組表
    if (setting.timetableCompactHeader) addStyle('timetableCompactHeader');
    if (setting.timetableStickyProgram) addStyle('timetableStickyProgram');
    if (setting.timetableUninterestedChannel) {
      addStyle('timetableUninterestedChannel');
    }
    if (setting.customTimetableAvoidSidePanel) {
      addStyle('customTimetableAvoidSidePanel');
    }
    // ビデオ・見逃し視聴・ライブイベント
    if (setting.videoPadding) addStyle('videoPadding');
    // コメント
    if (setting.highlightNewComment) addStyle('highlightNewComment');
    if (setting.highlightFirstComment) addStyle('highlightFirstComment');
    if (setting.commentFontSize) addStyle('commentFontSize');
    applyTheme();
  };

  /**
   * チャンネルデータをロードする
   */
  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');
    }
  };

  /**
   * 設定を読み込んで設定欄に反映する
   */
  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');
    setSelect('theme', '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('updateCurrentChannelList', 'Boolean');
    setCheck('searchProgram', 'Boolean');
    // 番組表
    setCheck('timetableCompactHeader', 'Boolean');
    setCheck('timetableStickyProgram', 'Boolean');
    setCheck('timetableUninterestedChannel', 'Boolean');
    setCheck('customTimetable', 'Boolean');
    setCheck('customTimetableAvoidSidePanel', 'Boolean');
    setSelect('timetableDaysBefore', 'Number');
    setSelect('timetableDaysAfter', 'Number');
    updateChannelListUI();
    // ビデオ・見逃し視聴
    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('hideDuplicateComment', '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 uccl = document.getElementById(
      `${sid}_Settings-updateCurrentChannelList`,
    );
    if (uccl instanceof HTMLInputElement) {
      uccl.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 (lsState.warningRe && lsState.warningRe.length) {
        ngwwp.innerText = lsState.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) {
      const disabledTitle = `\n\nこの機能の利用には${dpid}スクリプトが必要です。`;
      const checkTitle = disabledTitle.trim();
      [
        'customTimetable',
        'customTimetableAvoidSidePanel',
        'groupSummary',
        'nextPrograms',
        'notifyNewChannel',
        'programInfo1',
        'programInfo2',
        'updateCurrentChannelList',
        'searchProgram',
        'timetableDaysAfter',
        'viewCounter',
      ].forEach((s) => {
        const e = document.getElementById(`${sid}_Settings-${s}`);
        if (e instanceof HTMLInputElement || e instanceof HTMLSelectElement) {
          e.disabled = true;
          const label = e.parentElement;
          if (label instanceof HTMLLabelElement) {
            // 既に警告文言が含まれていない場合のみ追加する
            if (!label.title.includes(checkTitle)) {
              label.title += disabledTitle;
            }
            label.style.color = 'gray';
          }
        } else if (e instanceof HTMLElement) {
          // 既に警告文言が含まれていない場合のみ追加する
          if (!e.title.includes(checkTitle)) {
            e.title += disabledTitle;
          }
          e.style.color = 'gray';
        }
      });
      updateFooterNoticeVisibility();
    }

    // グループ設定UIの同期
    tempGroupData = JSON.parse(JSON.stringify(setting.groupData));
    const eGroupList = document.getElementById(`${sid}_Settings-groupList`);
    const eGroupName = document.getElementById(`${sid}_Settings-groupName`);
    const eGroupSearchList = document.getElementById(
      `${sid}_Settings-groupSearchList`,
    );
    const eGroupSearchWord = document.getElementById(
      `${sid}_Settings-groupSearchWord`,
    );

    if (eGroupList instanceof HTMLSelectElement) {
      // セレクトボックスの選択肢(グループ名)を更新
      Array.from(eGroupList.options).forEach((opt) => {
        const groupId = opt.value;
        const gName = setting.groupData[groupId]?.name;
        opt.textContent = gName || `グループ${groupId}`;
      });

      // 現在選択されているグループの入力フィールドを更新
      if (
        eGroupName instanceof HTMLInputElement &&
        eGroupSearchList instanceof HTMLSelectElement &&
        eGroupSearchWord instanceof HTMLInputElement
      ) {
        const id = eGroupList.value;
        const index = parseInt(eGroupSearchList.value, 10) - 1;
        const group = tempGroupData[id] || {
          name: `グループ${id}`,
          searchWords: [],
        };
        eGroupName.value = group.name || `グループ${id}`;
        eGroupSearchWord.value = group.searchWords[index] || '';
      }
    }
  };

  /**
   * デバッグ用ログ
   * @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 > 20) {
                console.warn(
                  `${sid}: 正規表現の処理に時間がかかりすぎているため、このNGワードを一時的に無効化しました。 (${duration.toFixed(
                    2,
                  )}ms): ${re}`,
                );
                // 重いNGワードとして記録
                const reStr = re.toString();
                // すでにある場合は追加しない
                if (!lsState.warningRe) lsState.warningRe = [];
                if (!lsState.warningRe.includes(reStr)) {
                  lsState.warningRe.push(reStr);
                  saveState();
                }
                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 observeTimetable = () => {
    if (returnContentType() !== 'tt') {
      if (data.timetableObserver) {
        data.timetableObserver.disconnect();
        data.timetableObserver = null;
      }
      return;
    }
    if (data.timetableObserver) {
      applyTimetableUninterest();
      return;
    }

    const targetHeader = document.querySelector(
      selector.timetableHeaderContainer,
    );
    const targetColumn = document.querySelector(selector.timetableContainer);

    if (!targetHeader || !targetColumn) {
      // 両方のコンテナが見つかるまで待機
      setTimeout(observeTimetable, 1000);
      return;
    }

    data.timetableObserver = new MutationObserver(() => {
      clearTimeout(interval.applyTimetableUninterest);
      interval.applyTimetableUninterest = setTimeout(
        applyTimetableUninterest,
        100,
      );
    });
    data.timetableObserver.observe(targetHeader, { childList: true });
    data.timetableObserver.observe(targetColumn, { childList: true });
    applyTimetableUninterest();
  };

  /**
   * カスタム番組表を開く
   */
  const openCustomTimetable = () => {
    data.customTimetablePeriodOffset = 0;
    createCustomTimetableLayer();
    renderCustomTimetable('initial');
  };

  /**
   * 設定欄を開く
   */
  const openSettings = () => {
    const settings = document.querySelector(`#${sid}_Settings`);
    if (settings && settings.classList.contains(`${sid}_Settings_hidden`)) {
      checkWarningRe();
      loadSettings();
      updateChannelListUI();
      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(),
    };
  };

  /**
   * サブクエリ文字列を解析して検索条件オブジェクトを返す
   * @param {string} subQuery
   * @param {number} maxTimeMs
   * @returns {any}
   */
  const parseSearchCriteria = (subQuery, maxTimeMs) => {
    const queryStr = subQuery.trim();
    if (!queryStr) return null;

    const parts =
      queryStr.match(/"[^"]*"|[--]?[[[][^\]]]*[\]]]|\S+/g) || [];
    const flags = Object.fromEntries(
      SPECIAL_SEARCH_KEYS.map((k) => [k, false]),
    );
    const negFlags = Object.fromEntries(
      SPECIAL_SEARCH_KEYS.map((k) => [k, false]),
    );
    const channelFilters = [];
    const excludeChannelFilters = [];
    const includeKeywords = [];
    const excludeKeywords = [];
    let dateRange = null; // {start, end}
    const excludeDateRanges = [];

    const nowMs = Date.now();

    for (const part of parts) {
      if (part.startsWith('"') && part.endsWith('"') && part.length >= 2) {
        const val = part.slice(1, -1);
        if (val) includeKeywords.push(val);
        continue;
      }
      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),
        );
      if (SPECIAL_SEARCH_KEYS.includes(normPart)) {
        if (isNegative) negFlags[normPart] = true;
        else flags[normPart] = true;
        continue;
      }
      if (normPart.startsWith('[') && normPart.endsWith(']')) {
        const val = normPart.slice(1, -1);
        if (val) {
          if (isNegative) excludeChannelFilters.push(val);
          else channelFilters.push(val);
        }
        continue;
      }
      if (normPart.includes('-')) {
        const [nk1, nk2] = normPart.split('-');
        let r1 = parseDateKey(nk1);
        if (r1) {
          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;
          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;
          }
        }
      }
      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];
        const MS_PER_HOUR = 1000 * 60 * 60;
        const duration = val * (unit === 'd' ? MS_PER_HOUR * 24 : MS_PER_HOUR);
        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;
      }
      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;
      }
      if (isNegative) excludeKeywords.push(workingPart);
      else includeKeywords.push(workingPart);
    }
    return {
      flags,
      negFlags,
      channelFilters,
      excludeChannelFilters,
      includeKeywords,
      excludeKeywords,
      dateRange,
      excludeDateRanges,
    };
  };

  /**
   * キューにある通知を処理して表示する
   */
  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" alt="${escapeHTML(d.id)}">
  <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 {'initial'|'period'|'update'} [scrollMode='update'] スクロール制御モード
   */
  const renderCustomTimetable = async (scrollMode = 'update') => {
    // グループや期間が切り替わる際は開いている詳細を閉じる
    if (scrollMode === 'initial' || scrollMode === 'period') {
      document
        .getElementById(`${sid}_CustomTimetable-detail`)
        ?.classList.add(`${sid}_CustomTimetable-detail-hidden`);
    }

    const content = document.getElementById(`${sid}_CustomTimetable-content`);
    const select = /** @type {HTMLSelectElement} */ (
      document.getElementById(`${sid}_CustomTimetable-groupSelect`)
    );
    if (!content || !select || !db) return;

    const groupId = parseInt(select.value, 10);
    let channelIds = Object.keys(setting.channelGroups).filter(
      (id) => setting.channelGroups[id] === groupId,
    );

    // 設定されたキーワードを取得
    const groupData = setting.groupData[String(groupId)] || { searchWords: [] };
    const validSearchWords = groupData.searchWords.filter((w) => w && w.trim());

    if (channelIds.length === 0 && validSearchWords.length === 0) {
      content.innerHTML = `
<div style="padding: 16px;">
  <p style="font-weight: bold;">カスタム番組表</p>
  <p style="margin-top: 16px;">
    検索キーワードやチャンネルを設定することで、任意のチャンネルをグループごとに表示できます。<br>
    設定はABEMA Little Toolsの設定欄→番組表タブ→「グループ・チャンネル」から行ってください。
  </p>
  <p style="margin-top: 16px;">
    詳細は右上の「?」をクリックしてヘルプを参照してください。
  </p>
</div>
      `;
      return;
    }

    content.innerHTML = '<div style="padding: 16px;">読み込み中...</div>';

    // 選択されたチャンネルのSlotsを取得
    let slots = [];
    if (channelIds.length > 0) {
      slots = await db.timetableSlots
        .where('channelId')
        .anyOf(channelIds)
        .toArray();
    }

    if (slots.length === 0 && validSearchWords.length === 0) {
      content.innerHTML =
        '<div style="padding: 16px;">番組データが見つかりませんでした。データが取得されるまでしばらくお待ちください。</div>';
      return;
    }

    const nowUnixForPeriod = Math.floor(Date.now() / 1000);
    const DAY = 86400; // 1日 = 86400秒
    const WEEK = DAY * 7;
    const TWO_WEEKS = DAY * 14;
    const daysBefore =
      setting.timetableDaysBefore === undefined
        ? 1
        : Number(setting.timetableDaysBefore);
    const daysAfter =
      setting.timetableDaysAfter === undefined
        ? 1
        : Number(setting.timetableDaysAfter);
    const periodOffset = data.customTimetablePeriodOffset;

    // 今日の日本時間0時(JST = UTC+9)に揃えたUnixTime
    const todayStartJst =
      Math.floor((nowUnixForPeriod + 32400) / DAY) * DAY - 32400;
    // 基準期間の範囲
    const baseStart = todayStartJst - daysBefore * DAY;
    const baseEnd = todayStartJst + (daysAfter + 1) * DAY;

    // periodOffsetに応じた表示範囲を計算(7段階)
    let startFilter, endFilter;
    // オフセット: -3=最古〜2週間前, -2=2週間前〜1週間前, -1=1週間前〜基準前日,
    //            0=基準期間, 1=基準後日〜1週間後, 2=1週間後〜2週間後, 3=2週間後〜最新
    switch (periodOffset) {
      case -3:
        startFilter = -Infinity;
        endFilter = todayStartJst - TWO_WEEKS + DAY;
        break;
      case -2:
        startFilter = todayStartJst - TWO_WEEKS;
        endFilter = todayStartJst - WEEK + DAY;
        break;
      case -1:
        startFilter = todayStartJst - WEEK;
        endFilter = baseStart + DAY;
        break;
      default: // 0: 基準期間
        startFilter = baseStart;
        endFilter = baseEnd;
        break;
      case 1:
        startFilter = baseEnd - DAY;
        endFilter = todayStartJst + WEEK + DAY;
        break;
      case 2:
        startFilter = todayStartJst + WEEK;
        endFilter = todayStartJst + TWO_WEEKS + DAY;
        break;
      case 3:
        startFilter = todayStartJst + TWO_WEEKS;
        endFilter = Infinity;
        break;
    }

    // ボタンのdisabledとラベルの更新
    const formatMD = (u) => {
      if (u === -Infinity || u === Infinity) return '';
      const d = new Date(u * 1000);
      const m = String(d.getMonth() + 1).padStart(2, '0');
      const date = String(d.getDate()).padStart(2, '0');
      return `${m}/${date}`;
    };

    const periodLabelMap = {
      '-3': `${formatMD(todayStartJst - TWO_WEEKS)} 以前`,
      '-2': `${formatMD(todayStartJst - TWO_WEEKS)} ~ ${formatMD(todayStartJst - WEEK)}`,
      '-1': `${formatMD(todayStartJst - WEEK)} ~ ${formatMD(baseStart)}`,
      0:
        formatMD(baseStart) === formatMD(baseEnd - 1)
          ? formatMD(baseStart)
          : `${formatMD(baseStart)} ~ ${formatMD(baseEnd - 1)}`,
      1: `${formatMD(baseEnd - DAY)} ~ ${formatMD(todayStartJst + WEEK)}`,
      2: `${formatMD(todayStartJst + WEEK)} ~ ${formatMD(todayStartJst + TWO_WEEKS)}`,
      3: `${formatMD(todayStartJst + TWO_WEEKS)} 以降`,
    };
    const periodSelectEl = /** @type {HTMLSelectElement|null} */ (
      document.getElementById(`${sid}_CustomTimetable-period-select`)
    );
    if (periodSelectEl) {
      periodSelectEl.value = String(periodOffset);
      Array.from(periodSelectEl.options).forEach((opt) => {
        const val = opt.value;
        if (Object.hasOwn(periodLabelMap, val)) {
          opt.textContent = periodLabelMap[val];
        }
      });
    }
    const buttonStates = [
      { id: 'prev', disabled: periodOffset <= -3 },
      { id: 'next', disabled: periodOffset >= 3 },
    ];
    buttonStates.forEach(({ id, disabled }) => {
      const button = document.getElementById(
        `${sid}_CustomTimetable-period-${id}`,
      );
      if (button instanceof HTMLButtonElement) {
        button.disabled = disabled;
      }
    });

    // スロットを表示期間にフィルタリング
    const filteredSlots = slots.filter(
      (slot) => slot.startAt < endFilter && slot.endAt > startFilter,
    );

    let allSlotsInPeriod = null;
    if (validSearchWords.length > 0) {
      const allSlots = await db.timetableSlots
        .where('endAt')
        .above(startFilter)
        .toArray();
      allSlotsInPeriod = allSlots.filter((s) => s.startAt < endFilter);
      const addedSlotIds = new Set(filteredSlots.map((s) => s.id));
      const { scale, maxTimeMs } = await getDbInfo();
      const nowMs = Date.now();
      const nowDb = nowMs * scale;
      const onlineChannelIds = new Set();
      if (validSearchWords.some((q) => q.toLowerCase().includes('off'))) {
        await db.timetableSlots
          .where('endAt')
          .above(nowDb)
          .each((s) => {
            if (s.startAt <= nowDb) {
              onlineChannelIds.add(s.channelId);
            }
          });
      }

      // nextキーワードが使われている場合、全チャンネルの「本当の次」番組を特定
      const trueNextSlotIds = new Set();
      if (validSearchWords.some((q) => /[nn][ee][xx][tt]/i.test(q))) {
        const allFutureSlots = await db.timetableSlots
          .where('startAt')
          .above(nowDb)
          .toArray();
        const nextMap = new Map();
        allFutureSlots.forEach((s) => {
          if (!nextMap.has(s.channelId)) {
            nextMap.set(s.channelId, s);
          }
        });
        nextMap.forEach((s) => trueNextSlotIds.add(s.id));
      }

      validSearchWords.forEach((q) => {
        const subQueries = splitSearchQuery(q);
        subQueries.forEach((subQueryStr) => {
          const criteria = parseSearchCriteria(subQueryStr, maxTimeMs);
          if (!criteria) {
            return;
          }

          let results = allSlotsInPeriod.filter(
            (p) =>
              !addedSlotIds.has(p.id) &&
              !data.uninterestedChannels.has(p.channelId) &&
              testSlotAgainstCriteria(
                p,
                criteria,
                nowDb,
                onlineChannelIds,
                nowMs,
              ),
          );

          // nextキーワードの特殊処理 (チャンネルごとに1件のみ)
          if (criteria.flags.next) {
            results = results.filter((p) => trueNextSlotIds.has(p.id));
          }

          results.forEach((p) => {
            filteredSlots.push(p);
            addedSlotIds.add(p.id);
          });
        });
      });

      const addedChannelIds = new Set(channelIds);
      filteredSlots.forEach((s) => addedChannelIds.add(s.channelId));
      channelIds = Array.from(addedChannelIds);
      channelIds.sort((a, b) => {
        let ia = data.channelOrder.indexOf(a);
        let ib = data.channelOrder.indexOf(b);
        if (ia === -1) ia = 999;
        if (ib === -1) ib = 999;
        return ia - ib;
      });
    }

    // 表示期間内に番組があるチャンネルのみに絞り込む
    const slotsChannelIds = new Set(filteredSlots.map((s) => s.channelId));
    channelIds = channelIds.filter((id) => slotsChannelIds.has(id));

    const matchedSlotIds =
      validSearchWords.length > 0
        ? new Set(filteredSlots.map((s) => s.id))
        : null;

    if (filteredSlots.length === 0) {
      content.innerHTML =
        '<div style="padding: 16px;">この期間の番組情報が見つかりませんでした。</div>';
      return;
    }

    filteredSlots.sort((a, b) => a.startAt - b.startAt);

    // 表示する時間帯(Time Blocks)を計算(番組がない時間帯はスキップするため)
    /** @type {{start: number, end: number, startY?: number, height?: number}[]} */
    const blocks = [];
    filteredSlots.forEach((slot) => {
      const start = Math.floor((slot.startAt + 32400) / 3600) * 3600 - 32400; // 時間(Hour)単位に切り捨て
      const end = Math.ceil((slot.endAt + 32400) / 3600) * 3600 - 32400; // 時間(Hour)単位に切り上げ
      if (blocks.length === 0) {
        blocks.push({ start, end });
      } else {
        const last = blocks[blocks.length - 1];
        if (start <= last.end) {
          last.end = Math.max(last.end, end);
        } else {
          blocks.push({ start, end });
        }
      }
    });

    const GAP_HEIGHT = 10;
    const PX_PER_MIN = 2;
    const PX_PER_SEC = PX_PER_MIN / 60;

    let currentY = 0;
    blocks.forEach((block) => {
      block.startY = currentY;
      block.height = (block.end - block.start) * PX_PER_SEC;
      currentY += block.height + GAP_HEIGHT;
    });
    const totalHeight = currentY;
    const nowUnix = Math.floor(Date.now() / 1000);

    let fullSlotsToRender = filteredSlots;
    if (validSearchWords.length > 0 && allSlotsInPeriod) {
      const channelIdSet = new Set(channelIds);
      fullSlotsToRender = allSlotsInPeriod.filter((slot) =>
        channelIdSet.has(slot.channelId),
      );
      fullSlotsToRender.sort((a, b) => a.startAt - b.startAt);
    }
    /** @type {any} */ (content)._fullSlotsToRender = fullSlotsToRender;

    /**
     * 時間軸のステータスクラスを取得する
     * @param {number} h 判定対象のUnixTime(Hour)
     * @param {number} currentHourStart 現在時刻の時間(Hour)切り捨てUnixTime
     */
    const getTimeClassForHour = (h, currentHourStart) => {
      // 日本時間(JST)での日付開始時刻を計算
      const JST_OFFSET = 32400; // 9時間 (秒)
      const todayStart =
        Math.floor((currentHourStart + JST_OFFSET) / DAY) * DAY - JST_OFFSET;
      const hStart = Math.floor((h + JST_OFFSET) / DAY) * DAY - JST_OFFSET;

      if (hStart === todayStart) {
        if (h < currentHourStart) {
          return `${sid}_CustomTimetable-hour-prev`;
        }
        if (h === currentHourStart) {
          return `${sid}_CustomTimetable-hour-now`;
        }
        return `${sid}_CustomTimetable-hour-next`;
      }

      if (h < currentHourStart) {
        return `${sid}_CustomTimetable-hour-past`;
      }
      return `${sid}_CustomTimetable-hour-future`;
    };

    // UnixTime と Y座標の相互変換関数
    const getUnixY = (t) => {
      for (const block of blocks) {
        if (t < block.start) {
          return block.startY || 0;
        }
        if (t <= block.end) {
          return (block.startY || 0) + (t - block.start) * PX_PER_SEC;
        }
      }
      return currentY;
    };

    const getUnixFromY = (y) => {
      for (const block of blocks) {
        if (y < (block.startY || 0)) return block.start;
        if (y <= (block.startY || 0) + (block.height || 0)) {
          return block.start + (y - (block.startY || 0)) / PX_PER_SEC;
        }
      }
      return blocks.length ? blocks[blocks.length - 1].end : Date.now() / 1000;
    };
    /** @type {any} */ (content)._getUnixFromY = getUnixFromY;

    const DAY_NAMES = ['日', '月', '火', '水', '木', '金', '土'];
    /** @type {any} */ (content)._DAY_NAMES = DAY_NAMES;

    // チャンネルヘッダー領域の生成
    let channelsHtml = '';
    channelIds.forEach((id) => {
      const channelIdStr = escapeHTML(id);
      channelsHtml += `
        <div class="${sid}_CustomTimetable-channel-header">
          <img class="${sid}_CustomTimetable-channel-logo" src="${data.imageDomain}image/channels/${channelIdStr}/logo.png?height=48&width=128" alt="${channelIdStr}">
        </div>
      `;
    });

    // 時間軸領域の生成
    let timeAxisHtml = '';
    blocks.forEach((block) => {
      for (let h = block.start; h < block.end; h += 3600) {
        const y = getUnixY(h);
        const d = new Date(h * 1000);
        const showDate = h === block.start || d.getHours() === 0;

        let dateHtml = '';
        if (showDate) {
          const month = d.getMonth() + 1;
          const date = d.getDate();
          const day = DAY_NAMES[d.getDay()];
          dateHtml = `
            <div class="${sid}_CustomTimetable-hour-date">
              <div>${month}/${date}</div>
              <div>(${day})</div>
            </div>
          `;
        }

        const hourStr = String(d.getHours()).padStart(2, '0');
        const segmentHeight = 3600 * PX_PER_SEC;

        const currentHourStart =
          Math.floor((nowUnix + 32400) / 3600) * 3600 - 32400;
        const timeClass = getTimeClassForHour(h, currentHourStart);

        timeAxisHtml += `
          <div class="${sid}_CustomTimetable-hour ${timeClass}" data-hour="${h}" style="top: ${y}px; height: ${segmentHeight}px;">
            ${dateHtml}
            <div class="${sid}_CustomTimetable-hour-text">${hourStr}</div>
          </div>
        `;
      }
    });

    // カラム領域(番組ブロック)の生成
    let columnsHtml = '';
    channelIds.forEach((id) => {
      let programBlocksHtml = '';
      const channelSlots = fullSlotsToRender.filter((s) => s.channelId === id);

      blocks.forEach((block, bIdx) => {
        let currentTime = block.start;
        // 現在のブロックに重なる番組を抽出
        const blockSlots = channelSlots.filter(
          (s) => s.endAt > block.start && s.startAt < block.end,
        );

        blockSlots.forEach((slot) => {
          // ブロック内で番組開始まで空き時間がある場合
          if (slot.startAt > currentTime) {
            const gapHeight = (slot.startAt - currentTime) * PX_PER_SEC;
            programBlocksHtml += `<div class="${sid}_CustomTimetable-spacer" style="height: ${gapHeight}px;"></div>`;
            currentTime = slot.startAt;
          }

          // 番組の描画(ブロックの終了時間を超えないようにクリップ)
          const renderEnd = Math.min(slot.endAt, block.end);
          if (renderEnd > currentTime) {
            const height = (renderEnd - currentTime) * PX_PER_SEC;

            // 過去番組・放送中番組の判定
            const isPast = slot.endAt <= nowUnix;
            const isBroadcasting =
              slot.startAt <= nowUnix && nowUnix < slot.endAt;
            const isFuture = slot.startAt > nowUnix;
            const pastClass = isPast
              ? ` ${sid}_CustomTimetable-program-past`
              : '';
            const broadClass = isBroadcasting
              ? ` ${sid}_CustomTimetable-program-broadcasting`
              : '';
            const futureClass = isFuture
              ? ` ${sid}_CustomTimetable-program-future`
              : '';
            const isUnmatched = matchedSlotIds && !matchedSlotIds.has(slot.id);
            const unmatchedClass = isUnmatched
              ? ` ${sid}_CustomTimetable-program-unmatched`
              : '';

            // バッジの生成
            let badgesHtml = '';
            if (slot.mark?.newcomer) {
              badgesHtml += `<span class="${sid}_CustomTimetable-label-new">新</span>`;
            }
            if (slot.mark?.bingeWatching) {
              badgesHtml += `<span class="${sid}_CustomTimetable-label-binge">一挙</span>`;
            }
            if (slot.mark?.recommendation) {
              badgesHtml += `<span class="${sid}_CustomTimetable-label-pick">注目</span>`;
            }
            if (slot.mark?.live) {
              badgesHtml += `<span class="${sid}_CustomTimetable-label-live">生</span>`;
            }
            if (slot.mark?.first) {
              badgesHtml += `<span class="${sid}_CustomTimetable-label-first">初</span>`;
            }
            const lastBadgeHtml = slot.mark?.last
              ? `<span class="${sid}_CustomTimetable-label-last">終</span>`
              : '';

            const startMin = returnFormatDate(slot.startAt, 'minutes');
            // ツールチップ用
            const timeStr = `${returnFormatDate(slot.startAt)} 〜 ${returnFormatDate(slot.endAt)}`;
            const tooltip = `${timeStr}\n${slot.title}`;

            programBlocksHtml += `
              <div class="${sid}_CustomTimetable-program${pastClass}${broadClass}${futureClass}${unmatchedClass}" style="height: ${height}px;" data-slot-id="${escapeHTML(slot.id)}" data-start="${slot.startAt}" data-end="${slot.endAt}" title="${escapeHTML(tooltip)}">
                <div class="${sid}_CustomTimetable-program-inner">
                  <div class="${sid}_CustomTimetable-program-time">${startMin}</div>
                  <div class="${sid}_CustomTimetable-program-title">${badgesHtml}${escapeHTML(slot.title)}${lastBadgeHtml}</div>
                </div>
              </div>
            `;
            currentTime = renderEnd;
          }
        });

        // ブロックの最後に空き時間がある場合
        if (currentTime < block.end) {
          const gapHeight = (block.end - currentTime) * PX_PER_SEC;
          programBlocksHtml += `<div class="${sid}_CustomTimetable-spacer" style="height: ${gapHeight}px;"></div>`;
        }

        // 次のブロックとの間の GAP_HEIGHT用 spacer(最後のブロック以外)
        if (bIdx < blocks.length - 1) {
          programBlocksHtml += `<div class="${sid}_CustomTimetable-spacer" style="height: ${GAP_HEIGHT}px;"></div>`;
        }
      });

      columnsHtml += `<div class="${sid}_CustomTimetable-column" data-channel-id="${escapeHTML(id)}">
        ${programBlocksHtml}
      </div>`;
    });

    // 現在時刻線の生成(常に要素を作っておき定期処理で表示切替・座標更新)
    const nowY = getUnixY(nowUnix);
    const hasBroadcastingInitial = filteredSlots.some(
      (s) => s.startAt <= nowUnix && nowUnix < s.endAt,
    );
    const isNowLineVisible =
      hasBroadcastingInitial &&
      blocks.length > 0 &&
      nowUnix >= blocks[0].start &&
      nowUnix <= blocks[blocks.length - 1].end;
    const nowLineStyle = `top: ${nowY}px;${isNowLineVisible ? '' : ' display: none;'}`;
    const nowLineHtml = `<div id="${sid}_CustomTimetable-nowline" style="${nowLineStyle}"></div>`;

    content.innerHTML = `
      <div id="${sid}_CustomTimetable-header-row">
        <div id="${sid}_CustomTimetable-topleft">
          <div id="${sid}_CustomTimetable-topdate"></div>
          <div id="${sid}_CustomTimetable-topday"></div>
        </div>
        <div id="${sid}_CustomTimetable-channels-wrap">
          ${channelsHtml}
        </div>
      </div>
      <div id="${sid}_CustomTimetable-body" style="height: ${totalHeight}px;">
        <div id="${sid}_CustomTimetable-timeaxis">
          ${timeAxisHtml}
        </div>
        <div id="${sid}_CustomTimetable-columns">
          ${nowLineHtml}
          ${columnsHtml}
        </div>
      </div>
    `;

    // 指定位置へのスクロール処理
    if (scrollMode === 'initial' || scrollMode === 'period') {
      let targetUnix = nowUnix;
      if (scrollMode === 'period') {
        if (periodOffset > 0) {
          // 未来の期間: その期間の開始時刻(2日後、1週間後など)に合わせる
          targetUnix = startFilter;
        } else if (periodOffset < 0) {
          // 過去の期間: フィルタされたスロットの先頭(=一番上)
          targetUnix =
            filteredSlots.length > 0 ? filteredSlots[0].startAt : nowUnix;
        } else {
          // 基準期間: 現在時刻
          targetUnix = nowUnix;
        }
      }

      const targetY = getUnixY(targetUnix);
      content.scrollTop =
        scrollMode === 'period' && periodOffset < 0
          ? 0
          : Math.max(0, targetY - content.clientHeight / 2);
    }
    // 日付表示の初期更新 (スクロール位置決定後)
    const topleftDate = document.getElementById(
      `${sid}_CustomTimetable-topdate`,
    );
    const topleftDay = document.getElementById(`${sid}_CustomTimetable-topday`);
    if (topleftDate && topleftDay) {
      const scrollUnix = getUnixFromY(content.scrollTop);
      const d = new Date(scrollUnix * 1000);
      topleftDate.textContent = `${d.getMonth() + 1}/${d.getDate()}`;
      topleftDay.textContent = `(${DAY_NAMES[d.getDay()]})`;
    }

    // 動的更新(毎分)
    const updateTimetableDynamic = () => {
      const currentUnix = Math.floor(Date.now() / 1000);

      // nowlineの更新
      const nowLineEl = document.getElementById(
        `${sid}_CustomTimetable-nowline`,
      );
      if (nowLineEl) {
        const hasBroadcasting = filteredSlots.some(
          (s) => s.startAt <= currentUnix && currentUnix < s.endAt,
        );
        if (
          hasBroadcasting &&
          blocks.length > 0 &&
          currentUnix >= blocks[0].start &&
          currentUnix <= blocks[blocks.length - 1].end
        ) {
          nowLineEl.style.display = 'block';
          nowLineEl.style.top = `${getUnixY(currentUnix)}px`;
        } else {
          nowLineEl.style.display = 'none';
        }
      }

      // 時間軸ステータス更新
      const currentHourStart =
        Math.floor((currentUnix + 32400) / 3600) * 3600 - 32400;
      const hourEls = content.querySelectorAll(`.${sid}_CustomTimetable-hour`);
      hourEls.forEach((el) => {
        const h = parseInt(el.getAttribute('data-hour') || '0', 10);
        el.classList.remove(
          `${sid}_CustomTimetable-hour-past`,
          `${sid}_CustomTimetable-hour-prev`,
          `${sid}_CustomTimetable-hour-now`,
          `${sid}_CustomTimetable-hour-next`,
          `${sid}_CustomTimetable-hour-future`,
        );
        el.classList.add(getTimeClassForHour(h, currentHourStart));
      });

      // 番組のハイライト更新(過去/放送中ステータス付け替え)
      const programEls = content.querySelectorAll(
        `.${sid}_CustomTimetable-program`,
      );
      programEls.forEach((el) => {
        const start = parseInt(el.getAttribute('data-start') || '0', 10);
        const end = parseInt(el.getAttribute('data-end') || '0', 10);
        const isPast = end <= currentUnix;
        const isBroadcasting = start <= currentUnix && currentUnix < end;
        const isFuture = start > currentUnix;
        el.classList.toggle(`${sid}_CustomTimetable-program-past`, isPast);
        el.classList.toggle(
          `${sid}_CustomTimetable-program-broadcasting`,
          isBroadcasting,
        );
        el.classList.toggle(`${sid}_CustomTimetable-program-future`, isFuture);
      });
    };

    const scheduleNextUpdate = () => {
      const now = new Date();
      const msToNextMinute =
        60000 - (now.getSeconds() * 1000 + now.getMilliseconds());

      window.clearTimeout(interval.customTimetableUpdate);
      interval.customTimetableUpdate = window.setTimeout(() => {
        window.requestAnimationFrame(updateTimetableDynamic);
        scheduleNextUpdate();
      }, msToNextMinute);
    };

    // 初回実行とスケジュール設定
    updateTimetableDynamic();
    scheduleNextUpdate();
  };

  /**
   * スタイルを追加・削除する
   * @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})`;
    if (t === 'minutes') return minutes;
    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;
  };

  /**
   * NG IDの設定情報をローカルストレージに保存する
   */
  const saveIdSettings = () => {
    try {
      localStorage.setItem(`${sid}-Id`, JSON.stringify(lsId));
    } catch (e) {
      log(`${sid}: Failed to save id settings to localStorage.`, e, 'error');
    }
  };

  /**
   * 設定を保存する
   * @param {boolean} skipSave ストレージへの保存とUIからの取得をスキップするか
   */
  const saveSettings = (skipSave = false) => {
    /**
     * 設定欄のチェックボックスの値を取得する
     * @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();

    if (!skipSave) {
      // 全般
      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');
      getSelect('theme');
      // テレビ
      getCheck('closeSidePanel');
      getCheck('sidePanelCloseButton');
      getCheck('showProgramDetail');
      getCheck('hiddenButtonText');
      getCheck('viewCounter');
      getCheck('programInfo1');
      getCheck('programInfo2');
      getValue('programInfo2Num');
      getCheck('nextPrograms');
      getValue('nextProgramsNum');
      getCheck('updateCurrentChannelList');
      getCheck('searchProgram');
      // 番組表
      getCheck('timetableCompactHeader');
      getCheck('timetableStickyProgram');
      getCheck('timetableUninterestedChannel');
      getCheck('customTimetable');
      getCheck('customTimetableAvoidSidePanel');
      // ビデオ・見逃し視聴
      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('hideDuplicateComment');
      // 画質
      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(
        'customTimetableAvoidSidePanel',
        setting.customTimetableAvoidSidePanel,
      );
    }
    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;
    if (ls.theme !== setting.theme) {
      applyTheme();
    }
    ls.theme = setting.theme;

    // テレビ
    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;
    if (ls.updateCurrentChannelList !== setting.updateCurrentChannelList) {
      reStyle('updateCurrentChannelList', setting.updateCurrentChannelList);
      debouncedShowNextPrograms();
    }
    ls.updateCurrentChannelList = setting.updateCurrentChannelList;
    ls.searchProgram = setting.searchProgram;

    // 番組表
    if (ls.timetableCompactHeader !== setting.timetableCompactHeader) {
      reStyle('timetableCompactHeader', setting.timetableCompactHeader);
    }
    ls.timetableCompactHeader = setting.timetableCompactHeader;
    if (ls.timetableStickyProgram !== setting.timetableStickyProgram) {
      reStyle('timetableStickyProgram', setting.timetableStickyProgram);
    }
    ls.timetableStickyProgram = setting.timetableStickyProgram;
    if (
      ls.timetableUninterestedChannel !== setting.timetableUninterestedChannel
    ) {
      reStyle(
        'timetableUninterestedChannel',
        setting.timetableUninterestedChannel,
      );
    }
    ls.timetableUninterestedChannel = setting.timetableUninterestedChannel;
    if (ls.customTimetable !== setting.customTimetable) {
      updateCustomTimetableSideNav(setting.customTimetable);
    }
    ls.customTimetable = setting.customTimetable;
    if (
      ls.customTimetableAvoidSidePanel !== setting.customTimetableAvoidSidePanel
    ) {
      reStyle(
        'customTimetableAvoidSidePanel',
        setting.customTimetableAvoidSidePanel,
      );
    }
    ls.customTimetableAvoidSidePanel = setting.customTimetableAvoidSidePanel;
    let isCustomTimetableNeedsUpdate = false;
    if (!skipSave) {
      ['timetableDaysBefore', 'timetableDaysAfter'].forEach((key) => {
        getSelect(key);
      });
    }
    ['timetableDaysBefore', 'timetableDaysAfter'].forEach((key) => {
      if (ls[key] !== setting[key]) {
        ls[key] = setting[key];
        isCustomTimetableNeedsUpdate = true;
      }
    });

    if (!skipSave) {
      syncGroupSettingsFromUI();
    }

    if (JSON.stringify(ls.groupData) !== JSON.stringify(tempGroupData)) {
      isCustomTimetableNeedsUpdate = true;
    }

    ls.groupData = tempGroupData;
    setting.groupData = JSON.parse(JSON.stringify(tempGroupData));

    // カスタム番組表の初期状態(グループもキーワードも未設定)を判定
    const isInitialStateForName =
      Object.values(setting.channelGroups).filter((n) => n >= 1 && n <= 15)
        .length === 0 &&
      Object.values(setting.groupData).every(
        (g) => !g.searchWords || g.searchWords.every((w) => !w || !w.trim()),
      );

    Object.keys(setting.groupData).forEach((id) => {
      const name = setting.groupData[id].name || `グループ${id}`;
      const opt1 = document.querySelector(
        `#${sid}_Settings-groupList option[value="${id}"]`,
      );
      if (opt1) opt1.textContent = name;
      const opts = document.querySelectorAll(
        `.${sid}_Settings-channelGroup-select option[value="${id}"]`,
      );
      opts.forEach((o) => (o.textContent = name));
      const opt2 = document.querySelector(
        `#${sid}_CustomTimetable-groupSelect option[value="${id}"]`,
      );
      if (opt2) {
        // 番組表側のセレクトボックスのみ、初期状態かつグループ1なら「グループ」と表示する
        opt2.textContent =
          isInitialStateForName && id === '1' ? 'グループ' : name;
      }
    });

    /** @type {{[key: string]: number}} */
    let channelGroups = setting.channelGroups || {};
    let uninterestedChannels = Array.from(data.uninterestedChannels || []);

    if (!skipSave) {
      const groupSelects = document.querySelectorAll(
        `.${sid}_Settings-channelGroup-select`,
      );
      // 要素が存在する場合のみ再構築(不意の初期化防止)
      if (groupSelects.length > 0) {
        channelGroups = {};
        uninterestedChannels = [];
        groupSelects.forEach((select) => {
          if (select instanceof HTMLSelectElement) {
            const id = select.name;
            const val = parseInt(select.value, 10);
            if (id && val > 0) {
              channelGroups[id] = val;
              if (val === 16) {
                uninterestedChannels.push(id);
              }
            }
          }
        });
      }
    } else {
      channelGroups = setting.channelGroups;
      // data.uninterestedChannels から逆算
      uninterestedChannels = Array.from(data.uninterestedChannels || []);
    }

    if (JSON.stringify(ls.channelGroups) !== JSON.stringify(channelGroups)) {
      isCustomTimetableNeedsUpdate = true;
    }

    setting.channelGroups = channelGroups;
    data.uninterestedChannels = new Set(uninterestedChannels);
    if (content === 'tt') applyTimetableUninterest();
    ls.channelGroups = channelGroups;

    // 開いている場合は更新
    if (isCustomTimetableNeedsUpdate) {
      if (document.getElementById(`${sid}_CustomTimetable`)) {
        const groupSelectEl = /** @type {HTMLSelectElement|null} */ (
          document.getElementById(`${sid}_CustomTimetable-groupSelect`)
        );
        if (groupSelectEl) {
          const currentValue = groupSelectEl.value;
          const validGroupIds = new Set(
            Object.values(setting.channelGroups)
              .map(Number)
              .filter((n) => n >= 1 && n <= 15),
          );
          for (let i = 1; i <= 15; i++) {
            const gData = setting.groupData[String(i)];
            if (
              gData?.searchWords &&
              gData.searchWords.some((w) => w && w.trim())
            ) {
              validGroupIds.add(i);
            }
          }
          // 未設定の場合は最低1を表示(初期状態フラグも保持)
          const isInitialState = validGroupIds.size === 0;
          if (isInitialState) validGroupIds.add(1);

          groupSelectEl.innerHTML = Array.from(
            { length: 15 },
            (_, i) => `グループ${i + 1}`,
          )
            .map((defaultName, index) => {
              if (!validGroupIds.has(index + 1)) return '';
              // 初期状態(グループ未設定)のときはグループ番号なしの「グループ」と表示する
              const gName =
                isInitialState && index === 0
                  ? 'グループ'
                  : setting.groupData[String(index + 1)]?.name || defaultName;
              return `<option value="${index + 1}">${escapeHTML(gName)}</option>`;
            })
            .join('');
          if (groupSelectEl.querySelector(`option[value="${currentValue}"]`)) {
            groupSelectEl.value = currentValue;
          }
        }
        renderCustomTimetable('initial');
      }
    }

    // ビデオ・見逃し視聴
    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.hideDuplicateComment = setting.hideDuplicateComment;

    // 画質
    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 (!skipSave) {
      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 saveSpamSettings = () => {
    try {
      localStorage.setItem(`${sid}-SpamLevel`, JSON.stringify(lsSpam));
    } catch (e) {
      log(`${sid}: Failed to save spam settings to localStorage.`, e, 'error');
    }
  };

  /**
   * 状態・キャッシュデータをローカルストレージに保存する
   */
  const saveState = () => {
    try {
      localStorage.setItem(stateSid, JSON.stringify(lsState));
    } catch (e) {
      log(`${sid}: Failed to save state to localStorage.`, e, 'error');
    }
  };

  /**
   * 設定情報をローカルストレージに保存する
   */
  const saveStorage = () => {
    saveUserSettings();
    saveWordSettings();
    saveIdSettings();
  };

  /**
   * メインの設定情報をローカルストレージに保存する
   */
  const saveUserSettings = () => {
    try {
      localStorage.setItem(sid, JSON.stringify(ls));
    } catch (e) {
      log(`${sid}: Failed to save settings to localStorage.`, e, 'error');
    }
  };

  /**
   * NGワードの設定情報をローカルストレージに保存する
   */
  const saveWordSettings = () => {
    try {
      localStorage.setItem(`${sid}-Word`, JSON.stringify(lsWord));
    } catch (e) {
      log(`${sid}: Failed to save word 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;
      }

      const { scale, maxTimeMs } = await getDbInfo();
      const nowMs = Date.now();
      const nowDb = nowMs * scale;

      const subQueries = splitSearchQuery(q);
      const resultMap = new Map();

      const onlineChannelIds = new Set();
      if (q.toLowerCase().includes('off')) {
        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 (subQueryStr) => {
          try {
            const criteria = parseSearchCriteria(subQueryStr, maxTimeMs);
            if (!criteria) {
              return;
            }

            const { flags, dateRange } = criteria;

            let collection = db.timetableSlots.toCollection();
            if (flags.now) {
              collection = db.timetableSlots.where('endAt').above(nowDb);
            } else if (flags.past) {
              collection = db.timetableSlots.where('endAt').belowOrEqual(nowDb);
            } else if (flags.future || flags.next) {
              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();

            // 時間軸フィルタの精緻化
            if (flags.now) {
              results = results.filter((p) => p.startAt <= nowDb);
            }

            // メイン条件の判定
            results = results.filter((p) =>
              testSlotAgainstCriteria(
                p,
                criteria,
                nowDb,
                onlineChannelIds,
                nowMs,
              ),
            );

            // nextキーワードの特殊処理
            if (flags.next) {
              const nextMap = new Map();
              results.forEach((p) => {
                if (
                  !nextMap.has(p.channelId) ||
                  p.startAt < nextMap.get(p.channelId).startAt
                ) {
                  nextMap.set(p.channelId, p);
                }
              });
              results = Array.from(nextMap.values());
            }

            results.forEach((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();
    data.spamData = Object.create(null);
    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 = '';
    lsState.warningRe = lsState.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('theme', '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('timetableCompactHeader', 'Boolean', true);
    setValue('timetableStickyProgram', 'Boolean', true);
    setValue('timetableUninterestedChannel', 'Boolean', true);
    setValue('customTimetable', 'Boolean', true);
    setValue('customTimetableAvoidSidePanel', 'Boolean', false);
    setValue('timetableDaysBefore', 'Number', 1);
    setValue('timetableDaysAfter', 'Number', 1);
    // ビデオ・見逃し視聴
    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('hideDuplicateComment', '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 = () => {
    /** @type {NodeListOf<HTMLElement>} */
    const channelItems = document.querySelectorAll(selector.tvChannelListItem);
    if (channelItems.length > 0) {
      const currentOrder = [];
      channelItems.forEach((item) => {
        const href =
          item.getAttribute('href') ||
          item.querySelector('a[href]')?.getAttribute('href');
        if (href) {
          const channelId = href.split('/').pop()?.split('?')[0];
          if (channelId) currentOrder.push(channelId);
        }
      });
      if (currentOrder.length > 0) updateChannelOrder(currentOrder);
    }

    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;

    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 : [];

      // 現在放送中の番組情報を判定 (ギャップ解消ロジック)
      const nowSec = Date.now() / 1000;
      let currentSlot = /** @type {any} */ (
        channelData ? channelData.current : undefined
      );
      // 1. 個別データが古い、または無い場合はグローバルから探す
      if (
        !currentSlot ||
        nowSec < (currentSlot.startAt || 0) ||
        nowSec >= (currentSlot.endAt || 0)
      ) {
        const globalSlot = /** @type {any} */ (
          data.broadcastSlots.find((s) => s && s.channelId === channelId)
        );
        if (
          globalSlot &&
          nowSec >= (globalSlot.startAt || 0) &&
          nowSec < (globalSlot.endAt || 0)
        ) {
          // サムネイル情報を含む放送データをマージして保持する
          currentSlot = currentSlot
            ? { ...currentSlot, ...globalSlot }
            : globalSlot;
        }
      }

      // 設定された数だけ要素をチェック・作成 (0番目に現在の番組を入れるため num+1 まで)
      for (let i = 0; i <= num; i++) {
        const nextId = `${sid}_NextProgramItem__details_${i}`;
        let infoEl = /** @type {HTMLElement | null} */ (
          item.querySelector(`.${nextId}`)
        );
        const thumbId = `${sid}_NextProgramItem__thumbnail`;
        let thumbEl =
          i === 0
            ? /** @type {HTMLElement | null} */ (
                item.querySelector(`.${thumbId}`)
              )
            : null;

        // 番組データの決定
        const nextProgram = i === 0 ? currentSlot : programs[i - 1];
        const nextProgramCast = /** @type {any} */ (nextProgram);

        // 放送中番組のサムネイル処理
        if (i === 0) {
          // 設定がONなら、データの有無に関わらず要素を定在させる
          if (setting.updateCurrentChannelList) {
            if (!thumbEl) {
              thumbEl = document.createElement('div');
              thumbEl.className = `${thumbId}`;
              if (infoEl) {
                innerEl.insertBefore(thumbEl, infoEl);
              } else {
                innerEl.append(thumbEl);
              }
            }

            // サムネイル情報の取得
            const globalSlotForThumb = data.broadcastSlots.find(
              (s) => s && s.channelId === channelId,
            );
            let thumbObj = globalSlotForThumb?.thumbnails?.default;

            // ラグ解消: 番組表データに画像情報があれば、即時性を優先してそれを利用する
            if (nextProgramCast?.displayProgramId) {
              thumbObj = {
                id: nextProgramCast.displayProgramId,
                name: 'thumb001',
                version: String(
                  nextProgramCast.displayImageUpdatedAt || '20200413',
                ),
              };
            }

            if (thumbObj && thumbObj.id && thumbObj.name && thumbObj.version) {
              thumbEl.style.display = '';
              // チャンネル名を取得(オリジナルのロゴ画像の alt 属性から)
              const channelName =
                item
                  .querySelector('.com-tv-LinearChannelListItem__logo img')
                  ?.getAttribute('alt') || '';
              if (thumbEl.getAttribute('title') !== channelName) {
                thumbEl.setAttribute('title', channelName);
              }

              const thumbUrl = `${data.imageDomain}image/programs/${thumbObj.id}/${thumbObj.name}.png?height=72&quality=75&version=${thumbObj.version}&width=128`;
              // ロゴURL (version は 20200413 が一般的)
              const logoUrl = `${data.imageDomain}image/channels/${channelId}/logo.png?height=48&quality=75&version=20200413&width=128`;

              // 内部構造が未作成なら初期化
              if (!thumbEl.querySelector('.com-m-Thumbnail__image')) {
                // 元の ABEMA と同じ構造を再現
                thumbEl.className = `${thumbId}`;
                thumbEl.innerHTML = `
                  <div class="com-m-Thumbnail com-m-Thumbnail--loaded com-m-Thumbnail--rounded">
                    <img alt="" class="com-m-Thumbnail__image" width="112" height="63">
                    <div class="com-m-Thumbnail__border--with-faint-white com-m-Thumbnail__border--rounded"></div>
                  </div>
                  <div class="com-tv-LinearChannelListItem__overlay">
                    <div class="com-tv-LinearChannelListItem__logo">
                      <img alt="" width="64" height="24">
                    </div>
                  </div>
                `;
              }

              // 画像の更新
              const thumbImg = /** @type {HTMLImageElement} */ (
                thumbEl.querySelector('.com-m-Thumbnail__image')
              );
              if (thumbImg && thumbImg.src !== thumbUrl) {
                thumbImg.src = thumbUrl;
              }
              const logoImg = /** @type {HTMLImageElement} */ (
                thumbEl.querySelector('.com-tv-LinearChannelListItem__logo img')
              );
              if (logoImg && logoImg.src !== logoUrl) {
                logoImg.src = logoUrl;
              }
            } else {
              // データがない場合は非表示
              thumbEl.style.display = 'none';
            }
          } else if (thumbEl) {
            // 設定自体がOFFの場合は非表示
            thumbEl.style.display = 'none';
          }
        }

        // すべてのインデックス(0番目〜num番目)で要素を作成・維持する(位置揃えのため)
        const shouldExist = true;
        // 0番目の表示は設定に依存。データ不在でも設定ONなら「―」を表示して枠を確保する
        const shouldShow = i === 0 ? setting.updateCurrentChannelList : true;

        if (!shouldExist) {
          if (infoEl) infoEl.remove();
          continue;
        }

        // 要素がない場合は作成
        if (!infoEl) {
          infoEl = document.createElement('div');
          infoEl.className = `${sid}_NextProgramItem__details ${nextId}`;
          // サムネイル要素の追加を一時停止(共通のHTML構造にする)
          infoEl.innerHTML = `
            <p class="${sid}_NextProgramItem__title">
              <span class="${sid}_NextProgramItem__title-text"></span>
            </p>
            <p class="${sid}_NextProgramItem__broadcasting-date"></p>
          `;

          // 挿入位置の特定 (自分より後ろのインデックスを持つ要素があればその前に挿入)
          let referenceEl = null;
          for (let j = i + 1; j <= num; j++) {
            const nextMatch = item.querySelector(
              `.${sid}_NextProgramItem__details_${j}`,
            );
            if (nextMatch) {
              referenceEl = nextMatch;
              break;
            }
          }
          if (referenceEl) {
            innerEl.insertBefore(infoEl, referenceEl);
          } else {
            innerEl.appendChild(infoEl);
          }
        }

        // 表示・アクティブ状態の制御
        if (i === 0) {
          if (shouldShow) {
            infoEl.style.display = '';
            infoEl.classList.add(`${sid}_active`);
          } else {
            infoEl.style.display = 'none';
            infoEl.classList.remove(`${sid}_active`);
            continue; // 非表示時は中身の更新をスキップ
          }
        } else if (!shouldShow) {
          infoEl.remove();
          continue;
        }

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

        const nextTitle = nextProgram ? nextProgramCast.title : '―';
        const start = nextProgram ? (nextProgramCast.startAt || 0) * 1000 : 0;
        const end = nextProgram ? (nextProgramCast.endAt || 0) * 1000 : 0;

        const nextTime = nextProgram
          ? `${returnFormatDate(start, 'time')}〜${returnFormatDate(
              end,
              'time',
            )}`
          : '―';
        const nextTimeFull = nextProgram
          ? `${returnFormatDate(start)} 〜 ${returnFormatDate(end)}`
          : '';

        // 内容の更新
        const fullTooltip = nextProgram ? `${nextTitle}\n${nextTimeFull}` : '';
        if (infoEl instanceof HTMLDivElement) {
          const newTitle = fullTooltip || '';
          if (infoEl.getAttribute('title') !== newTitle) {
            infoEl.setAttribute('title', newTitle);
          }
        }
        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>watching</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));

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

  /**
   * クエリを分析して OR 単位で分割する (引用符内は無視)
   * @param {string} q
   * @returns {string[]}
   */
  const splitSearchQuery = (q) => {
    const normalizedQ = q.replace(/[“”"]/g, '"');
    const res = [];
    let current = '';
    let inQuote = false;
    for (let i = 0; i < normalizedQ.length; i++) {
      const char = normalizedQ[i];
      if (char === '"') inQuote = !inQuote;
      if (!inQuote) {
        if (char === '|' || char === '|') {
          res.push(current.trim());
          current = '';
          continue;
        }
        const slice4 = normalizedQ.slice(i, i + 4).toUpperCase();
        if (slice4 === ' OR ' || slice4 === ' OR ') {
          res.push(current.trim());
          current = '';
          i += 3;
          continue;
        }
      }
      current += char;
    }
    res.push(current.trim());
    return res.filter((s) => s);
  };

  /**
   * ページを開いて動画が表示されたら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');
  };

  /**
   * グループ設定をUIから一時データへ同期する
   */
  const syncGroupSettingsFromUI = () => {
    const eGroupList = document.getElementById(`${sid}_Settings-groupList`);
    const eGroupName = document.getElementById(`${sid}_Settings-groupName`);
    const eGroupSearchList = document.getElementById(
      `${sid}_Settings-groupSearchList`,
    );
    const eGroupSearchWord = document.getElementById(
      `${sid}_Settings-groupSearchWord`,
    );

    if (
      eGroupList instanceof HTMLSelectElement &&
      eGroupName instanceof HTMLInputElement &&
      eGroupSearchList instanceof HTMLSelectElement &&
      eGroupSearchWord instanceof HTMLInputElement
    ) {
      const id = eGroupList.value;
      const index = parseInt(eGroupSearchList.value, 10) - 1;
      if (!tempGroupData[id]) {
        tempGroupData[id] = { name: `グループ${id}`, searchWords: [] };
      }
      let nameVal = eGroupName.value;
      if (nameVal.length > 10) {
        nameVal = nameVal.substring(0, 10);
        eGroupName.value = nameVal;
      }
      tempGroupData[id].name = nameVal;
      tempGroupData[id].searchWords[index] = eGroupSearchWord.value;
    }
  };

  /**
   * サイドナビゲーションの同期用関数
   */
  const syncSideNavState = () => {
    const timetableItem = document.querySelector(
      selector.sideNaviTimetableItem,
    );
    const customItem = document.querySelector(
      `.${sid}_SideNaviCustomTimetable`,
    );
    if (
      !(
        timetableItem instanceof HTMLElement &&
        customItem instanceof HTMLElement
      )
    ) {
      return;
    }

    const selectorsToSync = [
      selector.sideNaviWrapper,
      selector.sideNaviLink,
      selector.sideNaviContent,
    ];
    selectorsToSync.forEach((sel) => {
      const s = timetableItem.querySelector(sel);
      const t = customItem.querySelector(sel);
      if (s instanceof HTMLElement && t instanceof HTMLElement) {
        // 全ての --closed クラスについて同期
        const allClosedClasses = [
          `${sel.slice(1)}--closed`, // ドットを削除してサフィックスを追加
        ];
        allClosedClasses.forEach((cls) => {
          if (s.classList.contains(cls)) {
            t.classList.add(cls);
          } else {
            t.classList.remove(cls);
          }
        });
      }
    });
  };

  /**
   * 番組が検索条件に合致するか判定する
   * @param {TimetableSlot} p
   * @param {any} criteria
   * @param {number} nowDb
   * @param {Set<string>} onlineChannelIds
   * @param {number} nowMs
   * @returns {boolean}
   */
  const testSlotAgainstCriteria = (
    p,
    criteria,
    nowDb,
    onlineChannelIds,
    nowMs,
  ) => {
    const {
      flags,
      negFlags,
      channelFilters,
      excludeChannelFilters,
      includeKeywords,
      excludeKeywords,
      excludeDateRanges,
    } = criteria;

    const getIsFree = (/** @type {TimetableSlot} */ s) => {
      const endAt = normalizeTimestamp(s.timeshiftFreeEndAt || 0);
      return (
        !!s.timeshiftFreeEndAt &&
        (normalizeTimestamp(s.startAt) > nowMs ? true : endAt > nowMs)
      );
    };
    const getIsPremium = (/** @type {TimetableSlot} */ s) => {
      const endAt = normalizeTimestamp(s.timeshiftEndAt || 0);
      return (
        !!s.timeshiftEndAt &&
        (normalizeTimestamp(s.startAt) > nowMs ? true : endAt > nowMs)
      );
    };
    const getIsNow = (/** @type {TimetableSlot} */ s) =>
      s.startAt <= nowDb && s.endAt > nowDb;

    const conditionMap = {
      binge: (/** @type {TimetableSlot} */ s) => !!s.mark?.bingeWatching,
      first: (/** @type {TimetableSlot} */ s) =>
        !!(s.mark?.first || s.labels?.includes('first')),
      free: getIsFree,
      future: (/** @type {TimetableSlot} */ s) => s.startAt > nowDb,
      last: (/** @type {TimetableSlot} */ s) =>
        !!(s.mark?.last || s.labels?.includes('last')),
      live: (/** @type {TimetableSlot} */ s) => !!s.mark?.live,
      new: (/** @type {TimetableSlot} */ s) => !!s.mark?.newcomer,
      now: getIsNow,
      off: (/** @type {TimetableSlot} */ s) =>
        !onlineChannelIds.has(s.channelId),
      past: (/** @type {TimetableSlot} */ s) => s.endAt <= nowDb,
      pick: (/** @type {TimetableSlot} */ s) => !!s.mark?.recommendation,
      premium: getIsPremium,
      next: (/** @type {TimetableSlot} */ s) => s.startAt > nowDb,
      watching: (/** @type {TimetableSlot} */ s) => {
        if (returnContentType() === 'tv') {
          const match = location.href.match(
            /^https:\/\/abema\.tv\/now-on-air\/([^/?#]+)/,
          );
          if (match && match[1]) {
            return s.channelId === match[1];
          }
        }
        return false;
      },
    };

    for (const key of SPECIAL_SEARCH_KEYS) {
      if (flags[key] && conditionMap[key] && !conditionMap[key](p)) {
        return false;
      }
      if (negFlags[key] && conditionMap[key] && conditionMap[key](p)) {
        return false;
      }
    }

    const cid = p.channelId?.toLowerCase() || '';
    const cname = data.channels[p.channelId]?.toLowerCase() || '';
    const matchChannel = (target) => {
      const t = target.toLowerCase();
      return cid.includes(t) || cname.includes(t);
    };
    if (channelFilters.length > 0 && !channelFilters.every(matchChannel)) {
      return false;
    }
    if (
      excludeChannelFilters.length > 0 &&
      excludeChannelFilters.some(matchChannel)
    ) {
      return false;
    }

    if (excludeDateRanges.length > 0) {
      const start = normalizeTimestamp(p.startAt);
      const end = normalizeTimestamp(p.endAt);
      if (excludeDateRanges.some((dr) => start < dr.end && end > dr.start)) {
        return false;
      }
    }

    if (includeKeywords.length > 0) {
      const title = (p.title || '').toLowerCase();
      const highlight = (p.highlight || '').toLowerCase();
      if (
        !includeKeywords.every((kw) => {
          const t = kw.toLowerCase();
          return title.includes(t) || highlight.includes(t);
        })
      ) {
        return false;
      }
    }
    if (excludeKeywords.length > 0) {
      const title = (p.title || '').toLowerCase();
      const highlight = (p.highlight || '').toLowerCase();
      if (
        excludeKeywords.some((kw) => {
          const t = kw.toLowerCase();
          return title.includes(t) || highlight.includes(t);
        })
      ) {
        return false;
      }
    }
    return true;
  };

  /**
   * 視聴中の番組情報が記載されている要素を表示/非表示
   */
  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`);
      }
    }
  };

  /**
   * チャンネルリストとグループ設定用UIを更新する
   */
  const updateChannelListUI = () => {
    const channelList = document.getElementById(`${sid}_Settings-channelList`);
    if (!channelList) return;
    channelList.innerHTML = '';

    // data.channelOrderに基づいてソートし、漏れているものは五十音順で最後に追加
    const sortedChannels = Object.entries(data.channels).sort((a, b) => {
      const indexA = data.channelOrder.indexOf(a[0]);
      const indexB = data.channelOrder.indexOf(b[0]);

      if (indexA !== -1 && indexB !== -1) return indexA - indexB;
      if (indexA !== -1) return -1;
      if (indexB !== -1) return 1;
      return a[1].localeCompare(b[1], 'ja');
    });

    if (sortedChannels.length === 0) {
      channelList.innerHTML = `<p style="font-size: 0.9em; color: gray; padding: 4px;">チャンネルデータがありません。${data.dataProviderRunning ? '<br>一度番組表を開くか、しばらくお待ちください。' : ''}</p>`;
      return;
    }

    const groups = Array.from({ length: 15 }, (_, i) => `グループ${i + 1}`).map(
      (defaultName, index) => {
        const gName = setting.groupData[String(index + 1)]?.name;
        return gName ? gName : defaultName;
      },
    );
    groups.push('興味なし');

    sortedChannels.forEach(([id, name]) => {
      const li = document.createElement('li');
      const nameSpan = document.createElement('span');
      nameSpan.textContent = name;
      li.appendChild(nameSpan);

      const select = document.createElement('select');
      select.className = `${sid}_Settings-channelGroup-select`;
      select.name = id;

      const blankOption = document.createElement('option');
      blankOption.value = '0';
      blankOption.text = '';
      select.appendChild(blankOption);

      groups.forEach((groupName, index) => {
        const option = document.createElement('option');
        option.value = String(index + 1);
        option.text = groupName;
        select.appendChild(option);
      });

      const currentGroup = setting.channelGroups[id] || 0;
      select.value = String(currentGroup);

      li.appendChild(select);
      channelList.appendChild(li);
    });
  };

  /**
   * チャンネルの並び順を更新・保存する
   * @param {string[]} ids チャンネルIDの配列
   */
  const updateChannelOrder = (ids) => {
    if (!ids || ids.length === 0) return;
    // 重複を排除し、現在の並び順と異なる場合のみ更新
    const newOrder = Array.from(new Set(ids));
    if (JSON.stringify(data.channelOrder) !== JSON.stringify(newOrder)) {
      data.channelOrder = newOrder;
      lsState.channelOrder = newOrder;
      // 負荷軽減のため保存をデバウンス (2秒)
      clearTimeout(interval.updateChannelOrder);
      interval.updateChannelOrder = setTimeout(() => {
        saveState();
        log(
          'updateChannelOrder: チャンネルの並び順を更新・保存しました',
          newOrder.length,
        );
      }, 2000);
    }
  };

  /**
   * サイドナビゲーションのカスタム番組表アクセスの表示・非表示を切り替える
   * @param {boolean} flag setting.customTimetable
   */
  const updateCustomTimetableSideNav = (flag) => {
    const existingItem = document.querySelector(
      `.${sid}_SideNaviCustomTimetable`,
    );
    if (!flag) {
      if (existingItem) {
        const anchor = existingItem.querySelector('a');
        anchor?.removeEventListener('click', handleCustomTimetableSideNavClick);
        existingItem.remove();
      }
      observerS.disconnect();
      return;
    }
    if (existingItem || !data.dataProviderRunning) return;

    const timetableItem = document.querySelector(
      selector.sideNaviTimetableItem,
    );
    if (!timetableItem) return;

    const customItem = /** @type {HTMLLIElement} */ (
      timetableItem.cloneNode(true)
    );
    customItem.classList.add(`${sid}_SideNaviCustomTimetable`);

    const anchor = customItem.querySelector('a');
    if (anchor instanceof HTMLAnchorElement) {
      anchor.removeAttribute('href');
      anchor.style.cursor = 'pointer';
      anchor.removeAttribute('aria-current');
      anchor.classList.remove(`${sid}_SideNaviTimetable`); // 元のクラスがあれば削除
      anchor.addEventListener('click', handleCustomTimetableSideNavClick);
    }

    const sideText = customItem.querySelector(selector.sideNaviTextSide);
    if (sideText instanceof HTMLParagraphElement) {
      sideText.innerText = 'カスタム番組表';
    }
    const bottomText = customItem.querySelector(selector.sideNaviTextBottom);
    if (bottomText instanceof HTMLParagraphElement) {
      bottomText.innerText = 'カスタム';
    }

    // アイコンの選択状態クラスなどを解除
    const content = customItem.querySelector(selector.sideNaviContent);
    content?.classList.remove(
      'com-application-SideNavPrimaryItem__content--selected',
    );
    sideText?.classList.remove(
      'com-application-SideNavPrimaryItem__text--selected',
    );
    bottomText?.classList.remove(
      'com-application-SideNavPrimaryItem__text--selected',
    );

    timetableItem.after(customItem);

    // 初期同期
    syncSideNavState();

    // 監視開始
    observerS.disconnect();
    observerS.observe(timetableItem, {
      attributes: true,
      attributeFilter: ['class'],
      subtree: true,
    });
  };

  /**
   * DataProviderの告知の表示状態を更新する
   */
  const updateFooterNoticeVisibility = () => {
    const notice = document.getElementById(`${sid}_Settings-footer-notice`);
    if (!(notice instanceof HTMLDivElement)) return;

    if (data.dataProviderRunning) {
      notice.style.display = 'none';
      return;
    }

    const getChecked = (id) => {
      const el = document.getElementById(id);
      return el instanceof HTMLInputElement ? el.checked : false;
    };

    const generalChecked = getChecked(`${sid}_Settings-Tab-General`);
    const tvChecked = getChecked(`${sid}_Settings-Tab-Tv`);
    const timetableChecked = getChecked(`${sid}_Settings-Tab-Timetable`);

    if (generalChecked || tvChecked || timetableChecked) {
      notice.style.display = 'block';
    } else {
      notice.style.display = 'none';
    }
  };

  /**
   * 視聴数を更新する
   * @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),
    observerL = new MutationObserver(checkLinearChannelListChange),
    observerS = new MutationObserver(syncSideNavState);

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