您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
YouTubeのメドレーとかのコメント欄にある曲順のやつを使ってチャプターみたいにできる
// ==UserScript== // @name YouTubeのコメントのタイムラインを使うやつ // @namespace http://tampermonkey.net/ // @version 0.9.1 // @description YouTubeのメドレーとかのコメント欄にある曲順のやつを使ってチャプターみたいにできる // @author Midra(@mdr_anm) // @match https://www.youtube.com/* // @icon https://www.google.com/s2/favicons?domain=youtube.com // @noframes // @grant none // ==/UserScript== { 'use strict'; window.MIDEXT = window.MIDEXT || {}; const MIDYT = window.MIDEXT.YouTube = { INTERVAL_MAX: 20, INTERVAL_TIME: 100, INTERVAL_TIME_PLAYING: 1000, INTERVAL_TIME_PAUSE: 2000, TEXT: { USE_TIMESTAMP: 'タイムスタンプを使用', }, SELECTORS: { INFO: '#info-contents > ytd-video-primary-info-renderer', VIDEO: '#player video.video-stream', PLAYER: '#movie_player', COMMENTS: '#comments', }, Elements: { info: null, infoExt: null, infoTime: null, infoTitle: null, playPauseBtn: null, player: null, video: null, comments: null, popup: null, popupItemPlaying: null, }, SVG: { PLAY: '<svg version="1.1" viewBox="0 0 36 36"><path d="M 12,26 18.5,22 18.5,14 12,10 z M 18.5,22 25,18 25,18 18.5,14 z"></path></svg>', PAUSE: '<svg version="1.1" viewBox="0 0 36 36"><path d="M 12,26 16,26 16,10 12,10 z M 21,26 25,26 25,10 21,10 z"></path></svg>', PREV: '<svg version="1.1" viewBox="0 0 36 36"><path d="m 12,12 h 2 v 12 h -2 z m 3.5,6 8.5,6 V 12 z"></path></svg>', NEXT: '<svg version="1.1" viewBox="0 0 36 36"><path d="M 12,24 20.5,18 12,12 V 24 z M 22,12 v 12 h 2 V 12 h -2 z"></path></svg>', PLAYLIST: '<svg version="1.1" viewBox="0 0 24 24" style="height:75%"><path d="M22,7H2v1h20V7z M13,12H2v-1h11V12z M13,16H2v-1h11V16z M15,19v-8l7,4L15,19z"></path></svg>', }, Templates: { useTimestampBtn: null, popup: null, }, Info: { title: '', author: '', isPlaying: true, isVideo: location.href.match(/^(https:\/\/www\.youtube\.com\/watch).+/) !== null, isLive: false, currentTime: 0, currentTimeFormatted: '00:00', }, log: (data) => console.log('【MIDEXT.YouTube】', data), errorLog: (data) => console.error('【MIDEXT.YouTube】', data), // セレクタを指定して要素を取得 getElement: (selector) => new Promise((resolve, reject) => { let cnt = 0; const interval = setInterval(() => { // 繰り返しが指定回数超えたらエラー if (++cnt > MIDYT.INTERVAL_MAX) { reject(`getElement()\n要素の取得に失敗しました\n[${selector}]`); clearInterval(interval); } // 要素を探す const info = document.querySelector(selector); if (info) { resolve(info); clearInterval(interval); } }, MIDYT.INTERVAL_TIME); }), // 要素を生成 generateElement: (tag = 'div', id, classList, child) => { const elem = document.createElement(tag); if (id) elem.id = id; if (classList) elem.classList.add(...classList); if (child) elem.appendChild(child); return elem; }, // 要素を生成(HTMLテキストから) generateElementByHTML: (html) => { const elem = document.createElement('div'); elem.insertAdjacentHTML('beforeend', html); return elem.firstElementChild; }, Timestamp: { data: [], idx: -1, // タイムスタンプを更新 update: () => { // 現在再生中のとこ if (MIDYT.Info.currentTime < MIDYT.Timestamp.data[0].time) { MIDYT.Timestamp.idx = -1; } else { const len = MIDYT.Timestamp.data.length; for (let i = 0; i < len; i++) { if (MIDYT.Timestamp.data[i].time <= MIDYT.Info.currentTime) { MIDYT.Timestamp.idx = i; } else break; } } MIDYT.Elements.infoTitle.textContent = MIDYT.Timestamp.data[MIDYT.Timestamp.idx]?.text; // ポップアップ表示されてたら表示を更新 const item_playing = MIDYT.Elements.popup?.children[MIDYT.Timestamp.idx]; if (MIDYT.Elements.popupItemPlaying !== item_playing) { MIDYT.Elements.popupItemPlaying?.classList.remove('midyt-playing'); MIDYT.Elements.popupItemPlaying = item_playing; MIDYT.Elements.popupItemPlaying?.classList.add('midyt-playing'); } }, // 指定したタイムスタンプの再生位置に移動 changeIdx: (idx) => { if (idx < 0 || MIDYT.Timestamp.data.length <= idx) { MIDYT.errorLog('指定したインデックスは範囲外です'); return; }; const timestamp = MIDYT.Timestamp.data[idx]; MIDYT.Timestamp.idx = idx; MIDYT.Elements.player?.seekTo(timestamp.time); MIDYT.Timestamp.update(); }, // 前へ prev: () => { if (MIDYT.Timestamp.data[MIDYT.Timestamp.idx]?.time + 5 < MIDYT.Info.currentTime) { MIDYT.Timestamp.changeIdx(MIDYT.Timestamp.idx); } else { const prevIdx = MIDYT.Timestamp.idx - 1; if (0 <= prevIdx) { MIDYT.Timestamp.changeIdx(prevIdx); } } }, // 次へ next: () => { const nextIdx = MIDYT.Timestamp.idx + 1; if (nextIdx < MIDYT.Timestamp.data.length) { MIDYT.Timestamp.changeIdx(nextIdx); } }, // 初期化 initialize: () => { MIDYT.Timestamp.data = []; MIDYT.Timestamp.idx = -1; }, }, // 現在の再生時間を更新 updateCurrentTime: () => { MIDYT.Info.currentTime = MIDYT.Elements.player?.getCurrentTime(); MIDYT.Elements.infoTime.textContent = MIDYT.Info.currentTimeFormatted = MIDYT.formatTimeBySec(MIDYT.Info.currentTime); }, // 動画の情報を更新 updateCurrentInfo: () => { const data = MIDYT.Elements.player?.getVideoData(); MIDYT.Info.title = data?.title; MIDYT.Info.author = data?.author; MIDYT.Info.isLive = data?.isLive; MIDYT.Info.isPlaying = MIDYT.Elements.player?.getPlayerState() === 1; MIDYT.Elements.playPauseBtn.dataset.midytPlay = MIDYT.Info.isPlaying ? '1' : '0'; }, // 00:00:00形式に変換 formatTimeBySec: (sec) => { const second = ('00' + Math.floor(sec % 60)).slice(-2); const minute = ('00' + Math.floor(sec / 60 % 60)).slice(-2); const hour = Math.floor(sec / 3600); return `${hour ? `${hour}:` : ''}${minute}:${second}`; }, // インデックスを桁数に合わせる formatIdx: (idx, len) => { const max = String(len).length; return ('0'.repeat(max) + idx).slice(max * -1); }, // URLから秒数を抽出 getTimeByUrl: (url) => Number((url.match(/t=(\d+)s/) || [])[1] || -1), // 1行ごとに要素をまとめる getElementsOfLine: (elements = []) => { const elementsOfLine = {}; let lineCnt = 0; for (const elem of elements) { elementsOfLine[lineCnt] = elementsOfLine[lineCnt] || []; if (!elem.textContent.match(/\n/)) { elementsOfLine[lineCnt].push(elem); } else { lineCnt++; } } return elementsOfLine; }, // 繰り返し(実質メイン) interval: () => { if (MIDYT.Timestamp.data.length > 1) { // 現在の情報を更新 MIDYT.updateCurrentInfo(); // 再生画面以外、もしくはライブだったら繰り返し終了 if (!MIDYT.Info.isVideo || MIDYT.Info.isLive) return; // 更新処理 MIDYT.updateCurrentTime(); MIDYT.Timestamp.update(); MIDYT.Task.clearInterval(MIDYT.Task.interval); MIDYT.Task.interval = setTimeout( MIDYT.interval, MIDYT.Info.isPlaying && MIDYT.Info.isVideo && !MIDYT.Info.isLive ? MIDYT.INTERVAL_TIME_PLAYING : MIDYT.INTERVAL_TIME_PAUSE ); } }, // ポップアップを開く openPopup: (target, child, left, top, width, height) => { MIDYT.closePopup(); MIDYT.Elements.popup = MIDYT.Templates.popup.cloneNode(true); if (child) MIDYT.Elements.popup.appendChild(child); if (left) MIDYT.Elements.popup.style.setProperty('left', left); if (top) MIDYT.Elements.popup.style.setProperty('top', top); if (width) MIDYT.Elements.popup.style.setProperty('width', width); if (height) MIDYT.Elements.popup.style.setProperty('max-height', height); target?.appendChild(MIDYT.Elements.popup); }, // ポップアップを閉じる closePopup: () => { MIDYT.Elements.popup?.remove(); MIDYT.Elements.popup = null; }, // 「タイムスタンプを使用」をクリック clickUseTimestamp: (e) => { MIDYT.Timestamp.initialize(); const parent = e.target.closest('#main'); const content = parent?.querySelector('#content > #content-text'); const elementsOfLine = MIDYT.getElementsOfLine(content?.children); // 行ごとに時間とテキストを判別して配列に入れる(ソートなし) for (const line in elementsOfLine) { const pushData = { time: null, text: '' }; for (const elem of elementsOfLine[line]) { if (elem.matches('a.yt-simple-endpoint[href^="/watch"]') && !pushData.time) { pushData.time = MIDYT.getTimeByUrl(elem.href); } else { pushData.text += elem.textContent.trim(); } } if (pushData.time !== null && pushData.text) MIDYT.Timestamp.data.push(pushData); } // 昇順にソート MIDYT.Timestamp.data.sort((a, b) => a.time - b.time); if (MIDYT.Timestamp.data.length > 1) { document.documentElement.scrollTop = 0; MIDYT.Elements.infoExt.classList.remove('midyt-isHidden'); MIDYT.interval(); } else { MIDYT.Elements.infoExt.classList.add('midyt-isHidden'); } }, // プレイリストボタンをクリック clickPlaylistBtn: (e) => { if (MIDYT.Elements.popup) { MIDYT.closePopup(); } else { const fragment = document.createDocumentFragment(); const len = MIDYT.Timestamp.data.length; for (let i = 0; i < len; i++) { const data = MIDYT.Timestamp.data[i]; const item = MIDYT.generateElementByHTML( ` <a class="midyt-playlist-item ytd-button-renderer"> <tp-yt-paper-button class="ytd-button-renderer"> <span class="midyt-playlist-idx">${MIDYT.formatIdx(i + 1, len)}</span> <span class="midyt-playlist-time midyt-vrL">${MIDYT.formatTimeBySec(data.time)}</span> <span class="midyt-playlist-title midyt-vrL">${data.text}</span> </tp-yt-paper-button> </a> ` ); item.addEventListener('click', (e) => MIDYT.Timestamp.changeIdx(i)); fragment.appendChild(item); } MIDYT.openPopup(MIDYT.Elements.infoExt, fragment, null, null, '400px', '300px'); } }, // infoExtがクリック clickInfoExt: (e) => { const btn = e.target.closest('.midyt-btn'); const action = btn?.dataset.midytAction; switch (action) { case 'list': MIDYT.clickPlaylistBtn(e); break; case 'prev': MIDYT.Timestamp.prev(); break; case 'next': MIDYT.Timestamp.next(); break; case 'play': MIDYT.Elements.player?.playVideo(); break; case 'pause': MIDYT.Elements.player?.pauseVideo(); break; } }, Task: { interval: null, clearInterval: (task) => { if (task) clearTimeout(task) }, }, Observers: { comments: { CONFIG: { childList: true, subtree: true, }, observer: null, }, }, }; // メイン (async function () { try { // 拡張機能用のスタイルを追加 document.head.insertAdjacentHTML('beforeend', ` <style> .midyt-isHidden { display: none !important; } .midyt-vrL { padding-left: 6px; margin-left: 6px; border-left: 1px solid var(--yt-spec-10-percent-layer); } .midyt-vrR { padding-right: 6px; margin-right: 6px; border-right: 1px solid var(--yt-spec-10-percent-layer); } #midyt-info { height: 20px; margin-top: -10px; margin-bottom: 5px; font-size: 14px; color: var(--yt-spec-text-secondary); } #midyt-info * { box-sizing: border-box; } #midyt-info tp-yt-paper-button { min-width: unset; padding: 0; text-align: unset; } #midyt-info-main { display: flex; flex-direction: row; flex-wrap: nowrap; height: inherit; } #midyt-info-control { display: flex; flex-direction: row; flex-wrap: nowrap; height: inherit; min-width: 120px; overflow: hidden; } #midyt-info-control .midyt-btn { display: block; flex: 1; width: 30px; height: 30px; margin-top: -5px; } #midyt-info-control .midyt-btn > tp-yt-paper-button { width: inherit; height: inherit; } #midyt-info-control .midyt-btn > tp-yt-paper-button > svg { width: inherit; height: inherit; fill: var(--yt-spec-text-secondary); } #midyt-info-play_pause[data-midyt-play="0"] > .midyt-btn[data-midyt-action="pause"], #midyt-info-play_pause[data-midyt-play="1"] > .midyt-btn[data-midyt-action="play"] { display: none; } #midyt-info-now { line-height: 20px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #midyt-popup { position: relative; top: 5px; z-index: 999; background-color: var(--paper-listbox-background-color, var(--primary-background-color)); box-shadow: var(--shadow-elevation-2dp_-_box-shadow); border-radius: 4px; max-width: 100vw; max-height: 250px; padding: 8px 0; overflow-y: scroll; } #midyt-popup > .midyt-playlist-item.midyt-playing { color: var(--yt-spec-text-primary); font-weight: bold; } #midyt-popup::-webkit-scrollbar { background-color: transparent; } #midyt-popup::-webkit-scrollbar-thumb { border-radius: 8px; border: 4px solid transparent; background-clip: content-box; background-color: var(--yt-spec-text-secondary); } .midyt-playlist-item > tp-yt-paper-button { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-transform: unset; line-height: 20px; padding: 0 10px !important; } </style> ` ); // 動画の情報を入れるとこを取得 MIDYT.Elements.info = await MIDYT.getElement(MIDYT.SELECTORS.INFO); // 拡張機能用の要素を追加 MIDYT.Elements.infoExt = MIDYT.generateElementByHTML( ` <div id="midyt-info" class="midyt-isHidden"> <div id="midyt-info-main"> <div id="midyt-info-control"> <a class="midyt-btn ytd-button-renderer" data-midyt-action="list"> <tp-yt-paper-button class="ytd-button-renderer">${MIDYT.SVG.PLAYLIST}</tp-yt-paper-button> </a> <a class="midyt-btn ytd-button-renderer" data-midyt-action="prev"> <tp-yt-paper-button class="ytd-button-renderer">${MIDYT.SVG.PREV}</tp-yt-paper-button> </a> <div id="midyt-info-play_pause" data-midyt-play="0"> <a class="midyt-btn ytd-button-renderer" data-midyt-action="play"> <tp-yt-paper-button class="ytd-button-renderer">${MIDYT.SVG.PLAY}</tp-yt-paper-button> </a> <a class="midyt-btn ytd-button-renderer" data-midyt-action="pause"> <tp-yt-paper-button class="ytd-button-renderer">${MIDYT.SVG.PAUSE}</tp-yt-paper-button> </a> </div> <a class="midyt-btn ytd-button-renderer" data-midyt-action="next"> <tp-yt-paper-button class="ytd-button-renderer">${MIDYT.SVG.NEXT}</tp-yt-paper-button> </a> </div> <div id="midyt-info-now"> <span id="midyt-info-time" class="midyt-vrL"></span> <span id="midyt-info-title" class="midyt-vrL"></span> </div> </div> </div> ` ); MIDYT.Elements.infoExt.addEventListener('click', MIDYT.clickInfoExt); MIDYT.Elements.info.insertAdjacentElement('afterbegin', MIDYT.Elements.infoExt); MIDYT.Elements.infoTime = document.getElementById('midyt-info-time'); MIDYT.Elements.infoTitle = document.getElementById('midyt-info-title'); MIDYT.Elements.playPauseBtn = document.getElementById('midyt-info-play_pause'); // テンプレート生成 MIDYT.Templates.useTimestampBtn = MIDYT.generateElementByHTML( ` <div class="midyt-comment-button ytd-comment-action-buttons-renderer"> <a class="ytd-button-renderer"> <tp-yt-paper-button class="ytd-button-renderer" style="font-size:1.3rem">${MIDYT.TEXT.USE_TIMESTAMP}</tp-yt-paper-button> </a> </div> ` ); MIDYT.Templates.popup = MIDYT.generateElementByHTML(`<div id="midyt-popup"></div>`); // プレイヤーを取得 MIDYT.Elements.player = await MIDYT.getElement(MIDYT.SELECTORS.PLAYER); // コメントの親要素を取得 MIDYT.Elements.comments = await MIDYT.getElement(MIDYT.SELECTORS.COMMENTS); // コメント一覧を監視 MIDYT.Observers.comments.observer = new MutationObserver((event) => { MIDYT.Observers.comments.observer.disconnect(); for (const evt of event) { const {addedNodes} = evt; if (!addedNodes?.length) continue; for (const added of addedNodes) { if (added.id !== 'toolbar') continue; const useTimestampBtn = MIDYT.Templates.useTimestampBtn.cloneNode(true); useTimestampBtn.addEventListener('click', MIDYT.clickUseTimestamp); added.appendChild(useTimestampBtn); } } MIDYT.Observers.comments.observer.observe(MIDYT.Elements.comments, MIDYT.Observers.comments.CONFIG); }); if (MIDYT.Info.isVideo) { MIDYT.Observers.comments.observer.observe(MIDYT.Elements.comments, MIDYT.Observers.comments.CONFIG); } // なんかのデータ読み込まれたときのイベントっぽい window.addEventListener('yt-page-data-updated', async (e) => { MIDYT.Observers.comments?.observer.disconnect(); MIDYT.Elements.infoExt.classList.add('midyt-isHidden'); MIDYT.Timestamp.initialize(); MIDYT.closePopup(); if (MIDYT.Info.isVideo = e.detail.pageType === 'watch') { MIDYT.Observers.comments?.observer.observe(MIDYT.Elements.comments, MIDYT.Observers.comments?.CONFIG); } }); MIDYT.Elements.video = await MIDYT.getElement(MIDYT.SELECTORS.VIDEO); MIDYT.Elements.video?.addEventListener('play', () => MIDYT.Elements.playPauseBtn.dataset.midytPlay = '1'); MIDYT.Elements.video?.addEventListener('pause', () => MIDYT.Elements.playPauseBtn.dataset.midytPlay = '0'); } catch (e) { MIDYT.errorLog(e) } })(); }