Last active
February 22, 2025 21:54
-
-
Save roflsunriz/0aa936cf4c42d932e6b64185c83a4ecb to your computer and use it in GitHub Desktop.
dAnime NicoComment Renderer : dアニメでニコニコ動画のコメントをレンダリングするプログラム
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name dAnime NicoComment Renderer | |
// @namespace https://gist.github.com/roflsunriz/0aa936cf4c42d932e6b64185c83a4ecb | |
// @author roflsunriz | |
// @version 8.8 | |
// @description dアニメでニコニコ動画のコメントを表示するのじゃ! | |
// @match *://animestore.docomo.ne.jp/* | |
// @connect www.nicovideo.jp | |
// @connect nicovideo.jp | |
// @connect *.nicovideo.jp | |
// @connect public.nvcomment.nicovideo.jp | |
// @grant GM_xmlhttpRequest | |
// @grant GM_addStyle | |
// @grant GM_getValue | |
// @grant GM_setValue | |
// @run-at document-start | |
// @updateURL https://gist.github.com/roflsunriz/0aa936cf4c42d932e6b64185c83a4ecb | |
// @downloadURL https://gist.github.com/roflsunriz/0aa936cf4c42d932e6b64185c83a4ecb/raw/93662f98963a02d619f2a09e4c3c321bb1a9d58d/dAnime-nicoComment.user.js | |
// ==/UserScript== | |
// グローバル変数を最初に宣言するのじゃ | |
let activeNotifications = []; | |
let notificationGap = 10; // 通知間の間隔(ピクセル) | |
class NicoCommentFetcher { | |
async getApiData(videoId) { | |
try { | |
const response = await this.makeRequest({ | |
method: "GET", | |
url: `https://www.nicovideo.jp/watch/${videoId}`, | |
}); | |
// HTML文字列からserver-responseのメタデータを探すのじゃ | |
const parser = new DOMParser(); | |
const doc = parser.parseFromString(response.responseText, "text/html"); | |
const metaElement = doc.querySelector('meta[name="server-response"]'); | |
if (!metaElement) { | |
throw new Error("server-responseが見つからないのじゃ..."); | |
} | |
// APIデータをパースするのじゃ | |
const apiData = JSON.parse(decodeURIComponent(metaElement.content)).data.response; | |
// seriesデータを含めて返すのじゃ | |
return { | |
apiData: apiData, | |
videoTitle: apiData.video.title, | |
threadKey: apiData.comment.nvComment.threadKey, | |
params: apiData.comment.nvComment.params, | |
server: apiData.comment.nvComment.server, | |
series: apiData.series || null, // シリーズ情報を追加 | |
}; | |
} catch (error) { | |
console.error("APIデータの取得に失敗したのじゃ...", error); | |
throw error; | |
} | |
} | |
makeRequest(options) { | |
return new Promise((resolve, reject) => { | |
GM_xmlhttpRequest({ | |
...options, | |
onload: resolve, | |
onerror: reject, | |
}); | |
}); | |
} | |
async getComments(apiData) { | |
try { | |
const url = `${apiData.server}/v1/threads`; | |
const response = await this.makeRequest({ | |
method: "POST", | |
url: url, | |
headers: { | |
"x-client-os-type": "others", | |
"X-Frontend-Id": 6, | |
"X-Frontend-Version": 0, | |
"Content-Type": "application/json", | |
}, | |
data: JSON.stringify({ | |
params: apiData.params, | |
threadKey: apiData.threadKey, | |
additionals: {}, | |
}), | |
}); | |
// レスポンスが空でないか確認するのじゃ | |
if (!response.responseText) { | |
throw new Error("サーバーからの応答が空なのじゃ..."); | |
} | |
return JSON.parse(response.responseText); | |
} catch (error) { | |
console.error("コメント取得エラーなのじゃ!", error); | |
console.error("エラーの詳細なのじゃ:", { | |
name: error.name, | |
message: error.message, | |
stack: error.stack, | |
}); | |
throw error; | |
} | |
} | |
} | |
class CommentRenderer { | |
constructor(settings) { | |
this.settings = settings; | |
this.canvas = null; | |
this.ctx = null; | |
this.comments = []; | |
this.videoElement = null; | |
this.isPlaying = true; | |
this.opacity = 0.75; | |
this.lastTime = 0; | |
this.commentDuration = 6500; | |
this.fontSize = 32; | |
this.defaultColor = "#FFFFFF"; | |
// レーン管理を改善 | |
this.lanes = new Array(20).fill(null); | |
this.laneHeight = 40; | |
this.maxCommentLength = 150; // コメントの最大長を設定 | |
// フレーム管理 | |
this.lastFrameTime = performance.now(); | |
this.deltaTime = 0; | |
this.targetFPS = 60; | |
this.frameInterval = 1000 / this.targetFPS; | |
this.finalComments = []; | |
this.isFinalPhase = false; | |
this.finalPhaseStartTime = 0; | |
} | |
calculateFontSize() { | |
if (!this.canvas) return; | |
// 画面の高さから適切なフォントサイズを計算(より多くの行を表示) | |
const targetLines = 15; // 目標の行数を増やす | |
this.fontSize = Math.floor(this.canvas.height / targetLines); | |
this.laneHeight = this.fontSize * 1.2; // 行間を調整 | |
} | |
initialize() { | |
if (!this.settings.isEnabled) { | |
return; | |
} | |
// キャンバスの初期化 | |
this.setupCanvas(); | |
// 動画の状態監視を強化 | |
this.videoElement.addEventListener("play", () => { | |
this.isPlaying = true; | |
this.lastTime = this.videoElement.currentTime * 1000; | |
}); | |
this.videoElement.addEventListener("pause", () => { | |
this.isPlaying = false; | |
}); | |
// シーク時の処理を改善 | |
this.videoElement.addEventListener("seeking", () => { | |
this.handleSeek(); | |
}); | |
// 再生速度変更時の処理を追加 | |
this.videoElement.addEventListener("ratechange", () => { | |
this.handleRateChange(); | |
}); | |
// アニメーションループ開始 | |
this.animate(); | |
} | |
handleSeek() { | |
// シーク時にコメントの状態をリセット | |
this.resetComments(); | |
this.lastTime = this.videoElement.currentTime * 1000; | |
// シーク先の時間に応じてコメントの表示状態を更新 | |
const currentTime = this.videoElement.currentTime * 1000; | |
this.comments.forEach((comment) => { | |
comment.isActive = false; | |
if (comment.vposMs <= currentTime && currentTime - comment.vposMs < this.commentDuration) { | |
comment.isActive = true; | |
comment.startTime = currentTime - comment.vposMs; | |
} | |
}); | |
} | |
handleRateChange() { | |
// 再生速度変更時にコメントの移動速度を調整 | |
const rate = this.videoElement.playbackRate; | |
this.comments.forEach((comment) => { | |
if (comment.isActive) { | |
comment.startTime = this.videoElement.currentTime * 1000 - comment.vposMs; | |
} | |
}); | |
} | |
resetComments() { | |
// コメントの表示状態をリセット | |
this.comments.forEach((comment) => { | |
comment.isActive = false; | |
comment.startTime = 0; | |
}); | |
} | |
setupCanvas() { | |
this.canvas = document.createElement("canvas"); | |
this.canvas.style.position = "absolute"; | |
this.canvas.style.top = "0"; | |
this.canvas.style.left = "0"; | |
this.canvas.style.pointerEvents = "none"; | |
this.canvas.style.zIndex = "1000"; | |
// video要素の親要素を正しく取得するのじゃ | |
const videoWrapper = this.videoElement.parentElement; | |
if (!videoWrapper) { | |
throw new Error("動画の親要素が見つからないのじゃ..."); | |
} | |
// キャンバスを追加 | |
videoWrapper.appendChild(this.canvas); | |
// コンテキストの取得 | |
this.ctx = this.canvas.getContext("2d"); | |
// サイズ設定 | |
this.resizeCanvas(); | |
window.addEventListener("resize", () => this.resizeCanvas()); | |
} | |
resizeCanvas() { | |
if (!this.videoElement) return; | |
const rect = this.videoElement.getBoundingClientRect(); | |
this.canvas.width = rect.width; | |
this.canvas.height = rect.height; | |
this.canvas.style.width = `${rect.width}px`; | |
this.canvas.style.height = `${rect.height}px`; | |
// キャンバスサイズ変更時にフォントサイズも再計算 | |
this.calculateFontSize(); | |
} | |
animate(timestamp) { | |
if (!this.lastFrameTime) { | |
this.lastFrameTime = timestamp; | |
} | |
const deltaTime = timestamp - this.lastFrameTime; | |
if (deltaTime >= this.frameInterval) { | |
if (this.isPlaying) { | |
this.renderComments(); | |
} | |
this.lastFrameTime = timestamp - (deltaTime % this.frameInterval); | |
} | |
requestAnimationFrame(this.animate.bind(this)); | |
} | |
renderComments() { | |
if (!this.ctx || !this.videoElement) return; | |
const currentTime = this.videoElement.currentTime * 1000; | |
const duration = this.videoElement.duration * 1000; | |
const remainingTime = duration - currentTime; | |
// 残り5秒で最終フェーズ開始 | |
if (remainingTime < 5000 && !this.isFinalPhase) { | |
this.activateFinalPhase(currentTime); | |
} | |
if (this.isFinalPhase) { | |
this.renderFinalComments(currentTime); | |
} else { | |
const playbackSpeed = this.videoElement.playbackRate; | |
// キャンバスをクリア | |
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); | |
this.ctx.globalAlpha = this.opacity; | |
// レーンの状態をリセット(より多くのレーンを使用) | |
this.lanes = new Array(Math.floor(this.canvas.height / this.laneHeight)).fill(null); | |
// アクティブなコメントをフィルタリング | |
const activeComments = this.comments.filter((comment) => { | |
const isInTimeRange = | |
comment.vposMs <= currentTime && currentTime - comment.vposMs < this.commentDuration; | |
// 画面外判定を修正(左端まで完全に移動してから非アクティブに) | |
if (comment.isActive && comment.x + this.measureCommentWidth(comment.body) < -10) { | |
comment.isActive = false; | |
return false; | |
} | |
return isInTimeRange; | |
}); | |
// コメントの初期化と描画 | |
activeComments.forEach((comment) => { | |
if (!comment.isActive) { | |
comment.isActive = true; | |
comment.startTime = currentTime; | |
comment.x = this.canvas.width; | |
comment.lane = this.findAvailableLane(comment); | |
// 速度計算を調整(より遅く) | |
const commentWidth = this.measureCommentWidth(comment.body); | |
const totalDistance = this.canvas.width + commentWidth; | |
// 速度 = 総移動距離 / 表示時間(秒)* 再生速度 | |
comment.speed = (totalDistance / (this.commentDuration / 1000)) * playbackSpeed; | |
} | |
// 位置の更新(線形移動) | |
const elapsed = currentTime - comment.vposMs; | |
comment.x = this.canvas.width - comment.speed * (elapsed / 1000); | |
// レーンを更新 | |
this.lanes[comment.lane] = comment; | |
// コメントを描画(画面内判定を緩和) | |
if (comment.x < this.canvas.width + 100) { | |
const y = comment.lane * this.laneHeight + this.fontSize; | |
this.drawComment(comment.body, comment.x, y, comment.color || this.defaultColor); | |
} | |
}); | |
} | |
} | |
activateFinalPhase(currentTime) { | |
this.isFinalPhase = true; | |
this.finalPhaseStartTime = currentTime; | |
// 通常コメントを非アクティブ化 | |
this.comments.forEach(comment => comment.isActive = false); | |
// 最終10秒間のコメントを収集 | |
this.finalComments = this.comments.filter(comment => | |
comment.vposMs >= this.videoElement.duration * 1000 - 10000 | |
).map(comment => ({ | |
...comment, | |
x: this.canvas.width, | |
baseSpeed: this.calculateFinalSpeed(comment), | |
startTime: currentTime - (this.videoElement.duration * 1000 - 10000 - comment.vposMs) | |
})); | |
} | |
calculateFinalSpeed(comment) { | |
const commentWidth = this.measureCommentWidth(comment.body); | |
const totalDistance = this.canvas.width + commentWidth; | |
return (totalDistance / 3000) * 1000; // 3秒で画面通過する速度 | |
} | |
renderFinalComments(currentTime) { | |
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); | |
this.finalComments.forEach(comment => { | |
const elapsed = currentTime - comment.startTime; | |
comment.x = this.canvas.width - comment.baseSpeed * (elapsed / 1000); | |
if (comment.x + this.measureCommentWidth(comment.body) > 0) { | |
const y = comment.lane * this.laneHeight + this.fontSize; // 通常のレーン位置を使用 | |
this.drawComment(comment.body, comment.x, y, comment.color || this.defaultColor); | |
} | |
}); | |
} | |
calculateCommentSpeed(comment) { | |
const commentWidth = this.measureCommentWidth(comment.body); | |
const totalDistance = this.canvas.width + commentWidth; | |
return totalDistance / (this.commentDuration / 1000); // ピクセル/秒 | |
} | |
findAvailableLane(newComment) { | |
const commentWidth = this.measureCommentWidth(newComment.body); | |
// 使用可能なレーンを探す(より均等な分布を目指す) | |
for (let i = 0; i < this.lanes.length; i++) { | |
const lane = this.lanes[i]; | |
if (!lane) { | |
return i; | |
} | |
// 既存コメントとの衝突チェック | |
const existingRight = lane.x + this.measureCommentWidth(lane.body); | |
if (existingRight < 0 || existingRight < this.canvas.width) { | |
return i; | |
} | |
} | |
// 空きレーンがない場合はランダムに割り当て | |
return Math.floor(Math.random() * this.lanes.length); | |
} | |
drawComment(text, x, y, color = this.defaultColor) { | |
this.ctx.font = `${this.fontSize}px Arial`; | |
this.ctx.fillStyle = color; | |
this.ctx.strokeStyle = "#000000"; | |
this.ctx.lineWidth = this.fontSize / 12; | |
// 実際に描画 | |
this.ctx.strokeText(text, x, y); | |
this.ctx.fillText(text, x, y); | |
} | |
measureCommentWidth(text) { | |
// コメントの横幅を計測(フォントサイズに合わせて修正) | |
this.ctx.font = `${this.fontSize}px Arial`; | |
return this.ctx.measureText(text).width; | |
} | |
addComment(comment) { | |
// NGコメントチェックを追加 | |
if (this.isNGComment(comment)) { | |
return; | |
} | |
// コメントオブジェクトに必要なプロパティを追加 | |
this.comments.push({ | |
...comment, | |
color: comment.color || this.defaultColor, | |
isActive: false, | |
x: this.canvas.width, | |
lane: undefined, | |
speed: 0, | |
}); | |
// コメントを時間順にソート | |
this.comments.sort((a, b) => a.vposMs - b.vposMs); | |
} | |
// NGコメントチェック関数を追加 | |
isNGComment(comment) { | |
const text = comment.body; | |
// NGワードチェック | |
if (this.settings.ngWords.some((word) => text.includes(word))) { | |
return true; | |
} | |
// NG正規表現チェック | |
try { | |
if ( | |
this.settings.ngRegexps.some((regexpStr) => { | |
const matches = regexpStr.match(/^\/(.+)\/$/); | |
if (!matches) return false; | |
const regexp = new RegExp(matches[1], "i"); | |
return regexp.test(text); | |
}) | |
) { | |
return true; | |
} | |
} catch (error) { | |
console.error("正規表現のチェックでエラーが発生したのじゃ...", error); | |
} | |
return false; | |
} | |
// コメント表示/非表示を切り替える関数を追加 | |
toggleCommentVisibility() { | |
if (!this.canvas) return; | |
this.settings.isCommentVisible = !this.settings.isCommentVisible; | |
this.canvas.style.display = this.settings.isCommentVisible ? "block" : "none"; | |
showNotification( | |
this.settings.isCommentVisible ? "コメントを表示したのじゃ!" : "コメントを非表示にしたのじゃ!", | |
"info" | |
); | |
} | |
} | |
function waitForElement(selector) { | |
return new Promise((resolve) => { | |
// まずdocument.bodyの準備を待つのじゃ | |
if (!document.body) { | |
document.addEventListener("DOMContentLoaded", () => { | |
waitForElement(selector).then(resolve); | |
}); | |
return; | |
} | |
// 要素がすでに存在するか確認するのじゃ | |
const element = document.querySelector(selector); | |
if (element) { | |
resolve(element); | |
return; | |
} | |
// 要素を監視するのじゃ | |
const observer = new MutationObserver((mutations) => { | |
const element = document.querySelector(selector); | |
if (element) { | |
observer.disconnect(); | |
resolve(element); | |
} | |
}); | |
// document.bodyの監視を開始するのじゃ | |
observer.observe(document.body, { | |
childList: true, | |
subtree: true, | |
}); | |
}); | |
} | |
// 設定管理クラスを追加するのじゃ | |
class CommentSettings { | |
constructor() { | |
this.defaultSettings = { | |
isEnabled: true, | |
opacity: 0.75, | |
lastVideoId: "", | |
lastVideoTitle: "", | |
lastVideoViewCount: "", | |
defaultColor: "#FFFFFF", | |
// NGコメント関連の設定を追加 | |
ngWords: [], | |
ngRegexps: [], | |
isCommentVisible: true, // コメント表示/非表示の状態 | |
}; | |
this.settings = null; // 初期値をnullに設定 | |
this.loadSettings(); // 初期化時に一度だけ読み込む | |
} | |
loadSettings() { | |
try { | |
const saved = GM_getValue("commentSettings"); | |
// 保存された設定がない場合はデフォルト値を使用 | |
this.settings = saved ? JSON.parse(saved) : { ...this.defaultSettings }; | |
} catch (error) { | |
console.error("設定の読み込みでエラーが発生したのじゃ...", error); | |
this.settings = { ...this.defaultSettings }; | |
} | |
} | |
saveSettings() { | |
// 現在の設定と新しい設定が同じ場合は保存をスキップ | |
const currentSettings = GM_getValue("commentSettings"); | |
const newSettings = JSON.stringify(this.settings); | |
if (currentSettings === newSettings) { | |
return; | |
} | |
try { | |
GM_setValue("commentSettings", newSettings); | |
} catch (error) { | |
console.error("設定の保存でエラーが発生したのじゃ...", error); | |
} | |
} | |
// ゲッターとセッターを修正 | |
get isEnabled() { | |
return this.settings.isEnabled; | |
} | |
set isEnabled(value) { | |
if (this.settings.isEnabled === value) return; // 値が同じ場合は更新しない | |
this.settings.isEnabled = value; | |
} | |
get opacity() { | |
return this.settings.opacity; | |
} | |
set opacity(value) { | |
if (this.settings.opacity === value) return; // 値が同じ場合は更新しない | |
this.settings.opacity = value; | |
} | |
get lastVideoId() { | |
return this.settings.lastVideoId; | |
} | |
set lastVideoId(value) { | |
if (this.settings.lastVideoId === value) return; // 値が同じ場合は更新しない | |
this.settings.lastVideoId = value; | |
} | |
get lastVideoTitle() { | |
return this.settings.lastVideoTitle; | |
} | |
set lastVideoTitle(value) { | |
if (this.settings.lastVideoTitle === value) return; | |
this.settings.lastVideoTitle = value; | |
} | |
get lastVideoViewCount() { | |
return this.settings.lastVideoViewCount; | |
} | |
set lastVideoViewCount(value) { | |
if (this.settings.lastVideoViewCount === value) return; | |
this.settings.lastVideoViewCount = value; | |
} | |
get defaultColor() { | |
return this.settings.defaultColor; | |
} | |
set defaultColor(value) { | |
if (this.settings.defaultColor === value) return; | |
this.settings.defaultColor = value; | |
this.saveSettings(); | |
} | |
// NGワード関連のゲッター/セッター | |
get ngWords() { | |
return this.settings.ngWords || []; | |
} | |
set ngWords(value) { | |
if (JSON.stringify(this.settings.ngWords) === JSON.stringify(value)) return; | |
this.settings.ngWords = value; | |
this.saveSettings(); | |
} | |
get ngRegexps() { | |
return this.settings.ngRegexps || []; | |
} | |
set ngRegexps(value) { | |
if (JSON.stringify(this.settings.ngRegexps) === JSON.stringify(value)) return; | |
this.settings.ngRegexps = value; | |
this.saveSettings(); | |
} | |
get isCommentVisible() { | |
return this.settings.isCommentVisible !== false; | |
} | |
set isCommentVisible(value) { | |
if (this.settings.isCommentVisible === value) return; | |
this.settings.isCommentVisible = value; | |
this.saveSettings(); | |
} | |
} | |
// メッセージを表示する汎用関数なのじゃ | |
async function showNotification(message, type = "info", duration = 3000) { | |
// document.bodyが準備できるまで待つのじゃ | |
await new Promise((resolve) => { | |
if (document.body) { | |
resolve(); | |
} else { | |
document.addEventListener("DOMContentLoaded", () => resolve()); | |
} | |
}); | |
const colors = { | |
success: { | |
bg: "rgba(76, 175, 80, 0.9)", | |
hover: "rgba(56, 142, 60, 0.9)", | |
}, | |
error: { | |
bg: "rgba(244, 67, 54, 0.9)", | |
hover: "rgba(211, 47, 47, 0.9)", | |
}, | |
warning: { | |
bg: "rgba(255, 152, 0, 0.9)", | |
hover: "rgba(245, 124, 0, 0.9)", | |
}, | |
info: { | |
bg: "rgba(33, 150, 243, 0.9)", | |
hover: "rgba(25, 118, 210, 0.9)", | |
}, | |
}; | |
const color = colors[type] || colors.info; | |
const message_div = document.createElement("div"); | |
message_div.style.cssText = ` | |
position: fixed; | |
top: 20px; | |
right: 20px; | |
background: ${color.bg}; | |
color: white; | |
padding: 12px 24px; | |
border-radius: 4px; | |
z-index: 9999; | |
animation: fadeOut ${duration / 1000}s forwards; | |
font-size: 14px; | |
box-shadow: 0 2px 5px rgba(0,0,0,0.2); | |
max-width: 400px; | |
white-space: pre-line; | |
transition: top 0.3s ease-in-out; | |
`; | |
message_div.textContent = message; | |
// 既存の通知の位置を下にずらすのじゃ | |
const totalHeight = activeNotifications.reduce((height, notification) => { | |
const rect = notification.getBoundingClientRect(); | |
return height + rect.height + notificationGap; | |
}, 0); | |
message_div.style.top = `${20 + totalHeight}px`; | |
document.body.appendChild(message_div); | |
activeNotifications.push(message_div); | |
// ホバー時のアニメーションを停止するのじゃ | |
message_div.addEventListener("mouseover", () => { | |
message_div.style.animation = "none"; | |
message_div.style.background = color.hover; | |
}); | |
message_div.addEventListener("mouseout", () => { | |
message_div.style.animation = `fadeOut ${duration / 1000}s forwards`; | |
message_div.style.background = color.bg; | |
}); | |
// 通知を削除する時、位置を調整するのじゃ | |
setTimeout(() => { | |
const index = activeNotifications.indexOf(message_div); | |
if (index > -1) { | |
activeNotifications.splice(index, 1); | |
// 残りの通知の位置を上に詰めるのじゃ | |
activeNotifications.forEach((notification, i) => { | |
const top = 20 + i * (notification.offsetHeight + notificationGap); | |
notification.style.top = `${top}px`; | |
}); | |
} | |
message_div.remove(); | |
}, duration); | |
// アニメーションのスタイルを追加 | |
if (!document.querySelector("#fadeOutAnimation")) { | |
const style = document.createElement("style"); | |
style.id = "fadeOutAnimation"; | |
style.textContent = ` | |
@keyframes fadeOut { | |
0% { opacity: 1; transform: translateX(0); } | |
70% { opacity: 1; transform: translateX(0); } | |
100% { opacity: 0; transform: translateX(20px); } | |
} | |
`; | |
document.head.appendChild(style); | |
} | |
} | |
// showSuccessMessage関数も非同期に変更するのじゃ | |
async function showSuccessMessage(videoTitle, viewCount) { | |
await showNotification(`コメント設定を更新しました!\n"${videoTitle}" (${viewCount}再生)`, "success"); | |
} | |
// 設定UIを作成する関数を更新するのじゃ | |
function createSettingsUI() { | |
const settingsContainer = document.createElement("div"); | |
settingsContainer.className = "nicoComment-settings"; | |
settingsContainer.innerHTML = ` | |
<div class="settings-header"> | |
<h3>dAnime NicoComment Renderer設定</h3> | |
</div> | |
<div class="settings-content"> | |
<div class="settings-row search-area"> | |
<input type="text" id="searchKeyword" placeholder="動画を検索..."> | |
<button id="searchButton" class="search-button">検索</button> | |
</div> | |
<div class="settings-row"> | |
<div id="searchResults" class="search-results"></div> | |
</div> | |
<div class="settings-row"> | |
<label for="defaultVideoId">現在の動画:</label> | |
<div class="current-video-info"> | |
<input type="text" id="defaultVideoId" placeholder="sm9" readonly> | |
<div id="currentVideoTitle" class="video-title-display"></div> | |
<div id="currentVideoViewCount" class="video-count-display"></div> | |
</div> | |
</div> | |
<div class="settings-row"> | |
<label>コメント色:</label> | |
<div class="color-settings"> | |
<div class="preset-colors"> | |
<button class="color-preset" data-color="#FFFFFF" style="background: #FFFFFF"></button> | |
<button class="color-preset" data-color="#FF0000" style="background: #FF0000"></button> | |
<button class="color-preset" data-color="#00FF00" style="background: #00FF00"></button> | |
<button class="color-preset" data-color="#0000FF" style="background: #0000FF"></button> | |
<button class="color-preset" data-color="#FFFF00" style="background: #FFFF00"></button> | |
<button class="color-preset" data-color="#FF00FF" style="background: #FF00FF"></button> | |
<button class="color-preset" data-color="#00FFFF" style="background: #00FFFF"></button> | |
</div> | |
<input type="color" id="customColor" value="#FFFFFF"> | |
<input type="text" id="colorCode" placeholder="#RRGGBB" maxlength="7"> | |
</div> | |
</div> | |
<div class="settings-row"> | |
<label for="defaultOpacity">透明度:</label> | |
<input type="range" id="defaultOpacity" min="0" max="1" step="0.1"> | |
<span id="opacityValue">0.75</span> | |
</div> | |
<div class="settings-row"> | |
<div class="checkbox-wrapper"> | |
<input type="checkbox" class="nicocomment-checkbox" id="defaultEnabled"> | |
<span>コメントを表示</span> | |
</div> | |
</div> | |
<div class="settings-row"> | |
<label>NGワード:</label> | |
<div class="ng-settings"> | |
<div class="ng-overlay-container"> | |
<textarea id="ngWords" placeholder="NGワードを1行に1つ入力するのじゃ" rows="4"></textarea> | |
<div class="ng-overlay">••••••••••</div> | |
</div> | |
</div> | |
</div> | |
<div class="settings-row"> | |
<label>NG正規表現:</label> | |
<div class="ng-settings"> | |
<div class="ng-overlay-container"> | |
<textarea id="ngRegexps" placeholder="/正規表現/を1行に1つ入力するのじゃ" rows="4"></textarea> | |
<div class="ng-overlay">••••••••••</div> | |
</div> | |
</div> | |
</div> | |
<div class="settings-row"> | |
<button id="saveSettings" class="save-button">設定を保存</button> | |
</div> | |
</div> | |
`; | |
// NGコメント関連のスタイルを追加 | |
GM_addStyle(` | |
.ng-settings { | |
flex: 1; | |
} | |
.ng-settings textarea { | |
width: 95%; | |
padding: 8px; | |
border: 1px solid #ddd; | |
border-radius: 4px; | |
font-family: monospace; | |
resize: vertical; | |
} | |
`); | |
// チェックボックスのスタイルを追加するのじゃ | |
GM_addStyle(` | |
.nicoComment-settings { | |
background: #fff; | |
border-radius: 8px; | |
padding: 16px; | |
margin: 16px 0; | |
box-shadow: 0 2px 4px rgba(0,0,0,0.1); | |
} | |
.settings-header h3 { | |
margin: 0 0 16px 0; | |
color: #333; | |
font-size: 16px; | |
} | |
.settings-content { | |
display: flex; | |
flex-direction: column; | |
gap: 12px; | |
} | |
.settings-row { | |
display: flex; | |
align-items: center; | |
gap: 8px; | |
} | |
.settings-row label { | |
min-width: 140px; | |
color: #555; | |
} | |
.settings-row input[type="text"] { | |
padding: 4px 8px; | |
border: 1px solid #ddd; | |
border-radius: 4px; | |
width: 120px; | |
} | |
.settings-row input[type="range"] { | |
width: 120px; | |
} | |
#opacityValue { | |
min-width: 40px; | |
color: #666; | |
} | |
/* チェックボックス周りのスタイルを修正 */ | |
.checkbox-wrapper { | |
display: flex; | |
align-items: center; | |
gap: 8px; | |
cursor: pointer; | |
} | |
.nicocomment-checkbox { | |
-webkit-appearance: none; | |
-moz-appearance: none; | |
appearance: none; | |
width: 18px; | |
height: 18px; | |
border: 2px solid #2196F3; | |
border-radius: 3px; | |
outline: none; | |
cursor: pointer; | |
position: relative; | |
background: white; | |
} | |
.nicocomment-checkbox:checked { | |
background: #2196F3; | |
} | |
.nicocomment-checkbox:checked::after { | |
content: ''; | |
position: absolute; | |
left: 5px; | |
top: 2px; | |
width: 5px; | |
height: 10px; | |
border: solid white; | |
border-width: 0 2px 2px 0; | |
transform: rotate(45deg); | |
} | |
.nicocomment-checkbox:hover { | |
border-color: #1976D2; | |
} | |
.checkbox-wrapper span { | |
user-select: none; | |
color: #333; | |
font-size: 14px; | |
} | |
/* 保存ボタンのスタイル */ | |
.save-button { | |
background: #2196F3; | |
color: white; | |
border: none; | |
padding: 8px 16px; | |
border-radius: 4px; | |
cursor: pointer; | |
font-size: 14px; | |
transition: background 0.2s; | |
} | |
.save-button:hover { | |
background: #1976D2; | |
} | |
/* 検索関連のスタイル */ | |
.search-area { | |
display: flex; | |
gap: 8px; | |
margin-bottom: 16px; | |
} | |
#searchKeyword { | |
flex: 1; | |
padding: 8px; | |
border: 1px solid #ddd; | |
border-radius: 4px; | |
} | |
.search-button { | |
background: #2196F3; | |
color: white; | |
border: none; | |
padding: 8px 16px; | |
border-radius: 4px; | |
cursor: pointer; | |
transition: background 0.2s; | |
} | |
.search-button:hover { | |
background: #1976D2; | |
} | |
.search-results { | |
width: 100%; | |
max-height: 300px; | |
overflow-y: auto; | |
border: 1px solid #ddd; | |
border-radius: 4px; | |
margin-top: 8px; | |
} | |
.search-result-item { | |
display: flex; | |
padding: 8px; | |
border-bottom: 1px solid #eee; | |
cursor: pointer; | |
transition: background 0.2s; | |
} | |
.search-result-item:hover { | |
background: #f5f5f5; | |
} | |
.search-result-item:last-child { | |
border-bottom: none; | |
} | |
.thumbnail { | |
width: 96px; | |
height: 72px; | |
object-fit: cover; | |
margin-right: 8px; | |
} | |
.video-info { | |
flex: 1; | |
} | |
.video-title { | |
font-weight: bold; | |
margin-bottom: 4px; | |
} | |
.video-meta { | |
font-size: 12px; | |
color: #666; | |
} | |
.search-result-item.selected { | |
background: #e3f2fd; | |
border-left: 4px solid #2196F3; | |
} | |
.video-meta { | |
font-size: 12px; | |
color: #666; | |
line-height: 1.4; | |
} | |
.current-video-info { | |
display: flex; | |
flex-direction: column; | |
gap: 4px; | |
flex: 1; | |
} | |
.video-title-display { | |
font-size: 14px; | |
color: #333; | |
margin-top: 4px; | |
word-break: break-all; | |
} | |
.video-count-display { | |
font-size: 12px; | |
color: #666; | |
} | |
#defaultVideoId { | |
background-color: #f5f5f5; | |
cursor: default; | |
} | |
.color-settings { | |
display: flex; | |
align-items: center; | |
gap: 8px; | |
} | |
.preset-colors { | |
display: flex; | |
gap: 4px; | |
} | |
.color-preset { | |
width: 24px; | |
height: 24px; | |
border: 2px solid #ccc; | |
border-radius: 4px; | |
cursor: pointer; | |
padding: 0; | |
} | |
.color-preset:hover { | |
border-color: #666; | |
} | |
.color-preset.selected { | |
border-color: #2196F3; | |
box-shadow: 0 0 4px rgba(33, 150, 243, 0.5); | |
} | |
#customColor { | |
width: 40px; | |
height: 24px; | |
padding: 0; | |
border: none; | |
cursor: pointer; | |
} | |
#colorCode { | |
width: 80px; | |
padding: 4px; | |
border: 1px solid #ddd; | |
border-radius: 4px; | |
} | |
`); | |
// 既存同様のスタイル設定 | |
GM_addStyle(` | |
.ng-settings { | |
flex: 1; | |
} | |
.ng-settings textarea { | |
width: 95%; | |
padding: 8px; | |
border: 1px solid #ddd; | |
border-radius: 4px; | |
font-family: monospace; | |
resize: vertical; | |
} | |
/* オーバーレイコンテナのスタイル */ | |
.ng-overlay-container { | |
position: relative; | |
} | |
.ng-overlay { | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
background: rgba(255, 255, 255, 0.99); | |
color: #aaa; | |
text-align: center; | |
line-height: 1.5; | |
border-radius: 4px; | |
pointer-events: all; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
user-select: none; | |
transition: opacity 0.2s ease-in-out; | |
} | |
.ng-overlay.hidden { | |
opacity: 0; | |
pointer-events: none; | |
} | |
`); | |
// ここからはイベントハンドラの初期化 | |
// DOM読み込み後の待機などは既存同様の処理 | |
setTimeout(() => { | |
const ngOverlayContainers = settingsContainer.querySelectorAll('.ng-overlay-container'); | |
ngOverlayContainers.forEach(container => { | |
const textarea = container.querySelector('textarea'); | |
const overlay = container.querySelector('.ng-overlay'); | |
// オーバーレイをクリックしたらテキストエリアにフォーカスを移す | |
overlay.addEventListener('click', () => { | |
textarea.focus(); | |
}); | |
// フォーカス時にオーバーレイを非表示にする | |
textarea.addEventListener('focus', () => { | |
overlay.classList.add('hidden'); | |
}); | |
// 失焦時に再表示する | |
textarea.addEventListener('blur', () => { | |
overlay.classList.remove('hidden'); | |
}); | |
}); | |
}, 100); | |
return settingsContainer; | |
} | |
// video要素を確実に待つ関数を改善するのじゃ | |
function waitForVideo() { | |
return new Promise((resolve) => { | |
// まずdocument.bodyの準備を待つのじゃ | |
if (!document.body) { | |
document.addEventListener("DOMContentLoaded", () => { | |
waitForVideo().then(resolve); | |
}); | |
return; | |
} | |
// 要素がすでに存在するか確認するのじゃ | |
const element = document.querySelector("video#video"); | |
if (element) { | |
resolve(element); | |
return; | |
} | |
// 要素を監視するのじゃ | |
const observer = new MutationObserver((mutations) => { | |
const video = document.querySelector("video#video"); | |
if (video && video.readyState >= 1) { | |
// HAVE_METADATA以上 | |
observer.disconnect(); | |
resolve(video); | |
} | |
}); | |
// document.bodyの監視を開始するのじゃ | |
observer.observe(document.body, { | |
childList: true, | |
subtree: true, | |
}); | |
}); | |
} | |
// 設定UIの初期化関数を非同期に修正 | |
async function initializeSettingsUI(settings, settingsContainer) { | |
// DOM要素が確実に存在するまで待つのじゃ | |
await new Promise((resolve) => setTimeout(resolve, 100)); | |
const defaultVideoId = settingsContainer.querySelector("#defaultVideoId"); | |
const defaultOpacity = settingsContainer.querySelector("#defaultOpacity"); | |
const defaultEnabled = settingsContainer.querySelector("#defaultEnabled"); | |
const opacityValue = settingsContainer.querySelector("#opacityValue"); | |
const searchResults = settingsContainer.querySelector("#searchResults"); | |
const searchButton = settingsContainer.querySelector("#searchButton"); | |
const searchKeyword = settingsContainer.querySelector("#searchKeyword"); | |
const saveButton = settingsContainer.querySelector("#saveSettings"); | |
const currentVideoTitle = settingsContainer.querySelector("#currentVideoTitle"); | |
const currentVideoViewCount = settingsContainer.querySelector("#currentVideoViewCount"); | |
if ( | |
!defaultVideoId || | |
!defaultOpacity || | |
!defaultEnabled || | |
!opacityValue || | |
!currentVideoTitle || | |
!currentVideoViewCount | |
) { | |
console.error("設定UI要素の取得に失敗したのじゃ...", { | |
defaultVideoId: !!defaultVideoId, | |
defaultOpacity: !!defaultOpacity, | |
defaultEnabled: !!defaultEnabled, | |
opacityValue: !!opacityValue, | |
currentVideoTitle: !!currentVideoTitle, | |
currentVideoViewCount: !!currentVideoViewCount, | |
}); | |
showNotification("設定UI要素の取得に失敗したのじゃ...", "error"); | |
return; | |
} | |
// 保存された設定値を表示 | |
defaultVideoId.value = settings.lastVideoId || ""; | |
currentVideoTitle.textContent = settings.lastVideoTitle || "動画が設定されていないのじゃ"; | |
currentVideoViewCount.textContent = settings.lastVideoViewCount || ""; | |
defaultOpacity.value = settings.opacity; | |
defaultEnabled.checked = settings.isEnabled; | |
opacityValue.textContent = settings.opacity.toFixed(2); | |
// イベントハンドラの設定 | |
defaultVideoId.addEventListener("input", () => { | |
settings.lastVideoId = defaultVideoId.value; | |
settings.saveSettings(); | |
}); | |
defaultOpacity.addEventListener("input", (e) => { | |
const value = parseFloat(e.target.value); | |
settings.opacity = value; | |
opacityValue.textContent = value.toFixed(2); | |
settings.saveSettings(); | |
}); | |
defaultEnabled.addEventListener("change", (e) => { | |
settings.isEnabled = e.target.checked; | |
settings.saveSettings(); | |
}); | |
saveButton.addEventListener("click", () => { | |
settings.lastVideoId = defaultVideoId.value; | |
settings.opacity = parseFloat(defaultOpacity.value); | |
settings.isEnabled = defaultEnabled.checked; | |
// NGコメント設定を保存 | |
settings.ngWords = ngWordsTextarea.value | |
.split("\n") | |
.map((word) => word.trim()) | |
.filter((word) => word); | |
settings.ngRegexps = ngRegexpsTextarea.value | |
.split("\n") | |
.map((regexp) => regexp.trim()) | |
.filter((regexp) => regexp); | |
settings.saveSettings(); | |
showNotification(`設定を保存したのじゃ! VideoId:${settings.lastVideoId}`, "success"); | |
}); | |
// 検索機能の初期化 | |
if (searchButton && searchKeyword && searchResults) { | |
searchButton.addEventListener("click", async () => { | |
const keyword = searchKeyword.value.trim(); | |
if (!keyword) return; | |
await performVideoSearch(keyword, settings); | |
}); | |
searchKeyword.addEventListener("keypress", (e) => { | |
if (e.key === "Enter") { | |
searchButton.click(); | |
} | |
}); | |
} | |
// カラー設定の初期化 | |
const colorPresets = settingsContainer.querySelectorAll(".color-preset"); | |
const customColor = settingsContainer.querySelector("#customColor"); | |
const colorCode = settingsContainer.querySelector("#colorCode"); | |
// 保存された色を設定 | |
if (settings.defaultColor) { | |
customColor.value = settings.defaultColor; | |
colorCode.value = settings.defaultColor; | |
// プリセットの選択状態を更新 | |
colorPresets.forEach((preset) => { | |
if (preset.dataset.color === settings.defaultColor) { | |
preset.classList.add("selected"); | |
} | |
}); | |
} | |
// プリセットカラーのクリックイベント | |
colorPresets.forEach((preset) => { | |
preset.addEventListener("click", () => { | |
const color = preset.dataset.color; | |
settings.defaultColor = color; | |
colorCode.value = color; | |
customColor.value = color; | |
settings.saveSettings(); | |
// 選択状態を更新 | |
colorPresets.forEach((p) => p.classList.remove("selected")); | |
preset.classList.add("selected"); | |
}); | |
}); | |
// カスタムカラーの変更イベント | |
customColor.addEventListener("input", (e) => { | |
const color = e.target.value; | |
settings.defaultColor = color; | |
colorCode.value = color; | |
settings.saveSettings(); | |
// プリセットの選択を解除 | |
colorPresets.forEach((p) => p.classList.remove("selected")); | |
}); | |
// カラーコードの入力イベント | |
colorCode.addEventListener("input", (e) => { | |
let color = e.target.value; | |
if (/^#[0-9A-Fa-f]{6}$/.test(color)) { | |
settings.defaultColor = color; | |
customColor.value = color; | |
settings.saveSettings(); | |
// プリセットの選択を解除 | |
colorPresets.forEach((p) => p.classList.remove("selected")); | |
} | |
}); | |
// NGコメント設定の初期化 | |
const ngWordsTextarea = settingsContainer.querySelector("#ngWords"); | |
const ngRegexpsTextarea = settingsContainer.querySelector("#ngRegexps"); | |
if (ngWordsTextarea && ngRegexpsTextarea) { | |
// 保存された設定を表示 | |
ngWordsTextarea.value = settings.ngWords.join("\n"); | |
ngRegexpsTextarea.value = settings.ngRegexps.join("\n"); | |
} | |
} | |
// 検索機能を一つの関数にまとめるのじゃ | |
async function performVideoSearch(keyword, settings) { | |
try { | |
// 検索結果表示要素を取得 | |
const searchResults = document.querySelector("#searchResults"); | |
const defaultVideoId = document.querySelector("#defaultVideoId"); | |
if (!searchResults || !defaultVideoId) { | |
showNotification("必要なDOM要素が見つからないのじゃ...", "error"); | |
throw new Error("必要なDOM要素が見つからないのじゃ..."); | |
} | |
searchResults.innerHTML = '<div class="search-result-item">検索中...</div>'; | |
// ニコニコ動画の検索APIを叩くのじゃ | |
const response = await new Promise((resolve, reject) => { | |
GM_xmlhttpRequest({ | |
method: "GET", | |
url: `https://www.nicovideo.jp/search/${encodeURIComponent(keyword)}`, | |
onload: (response) => { | |
response.status === 200 ? resolve(response) : reject(new Error(`Status ${response.status}`)); | |
}, | |
onerror: reject, | |
}); | |
}); | |
// 検索結果をパースするのじゃ | |
const parser = new DOMParser(); | |
const doc = parser.parseFromString(response.responseText, "text/html"); | |
const items = Array.from(doc.querySelectorAll(".item[data-video-id]")); | |
if (items.length === 0) { | |
searchResults.innerHTML = '<div class="search-result-item">動画が見つかりませんでした。</div>'; | |
showNotification("動画が見つかりませんでした。", "error"); | |
return null; | |
} | |
// 検索結果をHTMLに変換するのじゃ | |
searchResults.innerHTML = items | |
.map((item) => { | |
const link = item.querySelector("a"); | |
const thumbnail = item.querySelector("img"); | |
const title = item.querySelector(".itemTitle a")?.textContent || "タイトルなし"; | |
const videoId = link?.href?.match(/watch\/([a-z]{2}\d+)/)?.[1] || ""; | |
const viewCount = item.querySelector(".count.view .value")?.textContent || "0"; | |
const postedAt = item.querySelector(".time")?.textContent || ""; | |
return ` | |
<div class="search-result-item" data-video-id="${videoId}"> | |
<img class="thumbnail" src="${thumbnail?.src || ""}" alt="${title}"> | |
<div class="video-info"> | |
<div class="video-title">${title}</div> | |
<div class="video-meta"> | |
動画ID: ${videoId}<br> | |
再生数: ${viewCount}<br> | |
投稿日: ${postedAt} | |
</div> | |
</div> | |
</div> | |
`; | |
}) | |
.join(""); | |
// クリックイベントを設定するのじゃ | |
searchResults.querySelectorAll(".search-result-item").forEach((item) => { | |
item.addEventListener("click", () => { | |
const videoId = item.dataset.videoId; | |
if (videoId) { | |
defaultVideoId.value = videoId; | |
settings.lastVideoId = videoId; | |
settings.saveSettings(); | |
// 選択状態を更新 | |
searchResults.querySelectorAll(".search-result-item").forEach((i) => { | |
i.classList.toggle("selected", i === item); | |
}); | |
} | |
}); | |
}); | |
return items; | |
} catch (error) { | |
console.error("検索でエラーが発生したのじゃ...", error); | |
const searchResults = document.querySelector("#searchResults"); | |
if (searchResults) { | |
searchResults.innerHTML = '<div class="search-result-item">検索中にエラーが発生しました。</div>'; | |
} | |
showNotification("検索中にエラーが発生しました。", "error"); | |
return null; | |
} | |
} | |
// 自動検索機能を追加するのじゃ | |
async function autoSearchAndSetVideo(title, number, episode, settings) { | |
const searchQuery = `${title} ${number} ${episode}`; | |
const searchKeyword = document.querySelector("#searchKeyword"); | |
searchKeyword.value = searchQuery; | |
const items = await performVideoSearch(searchQuery, settings); | |
if (!items || items.length === 0) { | |
await showNotification( | |
`"${searchQuery}" に一致する動画が見つかりませんでした。\n検索キーワードを変更して試してみてください。`, | |
"error" | |
); | |
return null; | |
} | |
// 再生数でソートするのじゃ | |
const sortedItems = items.sort((a, b) => { | |
const getViewCount = (item) => { | |
const viewCountText = item.querySelector(".count.view .value")?.textContent || "0"; | |
return parseInt(viewCountText.replace(/,/g, ""), 10); | |
}; | |
return getViewCount(b) - getViewCount(a); | |
}); | |
// 最も再生数の多い動画を選択するのじゃ | |
const topVideo = sortedItems[0]; | |
const videoId = topVideo.querySelector("a")?.href?.match(/watch\/([a-z]{2}\d+)/)?.[1]; | |
const viewCount = topVideo.querySelector(".count.view .value")?.textContent || "0"; | |
const videoTitle = topVideo.querySelector(".itemTitle a")?.textContent || "タイトルなし"; | |
if (videoId) { | |
// 設定を更新 | |
const defaultVideoId = document.querySelector("#defaultVideoId"); | |
const currentVideoTitle = document.querySelector("#currentVideoTitle"); | |
const currentVideoViewCount = document.querySelector("#currentVideoViewCount"); | |
if (defaultVideoId && currentVideoTitle && currentVideoViewCount) { | |
defaultVideoId.value = videoId; | |
currentVideoTitle.textContent = videoTitle; | |
currentVideoViewCount.textContent = `再生数: ${viewCount}`; | |
settings.lastVideoId = videoId; | |
settings.lastVideoTitle = videoTitle; | |
settings.lastVideoViewCount = viewCount; | |
settings.saveSettings(); | |
} | |
// 検索結果の中から対応する要素を見つけて選択状態にするのじゃ! | |
const searchResults = document.querySelector("#searchResults"); | |
if (searchResults) { | |
// 一旦全ての選択状態をクリア | |
searchResults.querySelectorAll(".search-result-item").forEach((item) => { | |
item.classList.remove("selected"); | |
}); | |
// 選択した動画に対応する要素を見つけて選択状態にする | |
const selectedItem = searchResults.querySelector( | |
`.search-result-item[data-video-id="${videoId}"]` | |
); | |
if (selectedItem) { | |
selectedItem.classList.add("selected"); | |
// スクロールして選択した項目を表示 | |
selectedItem.scrollIntoView({ behavior: "smooth", block: "nearest" }); | |
} | |
} | |
// 成功メッセージを表示 | |
await showSuccessMessage(videoTitle, viewCount); | |
} else { | |
await showNotification("動画IDの取得に失敗しました。\n別の動画を試してみてください。", "error"); | |
} | |
return videoId; | |
} | |
// 視聴履歴の監視をMutationObserverに変更するのじゃ | |
async function observeHistoryChanges(settings) { | |
try { | |
// 最初の視聴履歴カードを待つのじゃ | |
const initialCard = await waitForElement(".itemModule.list"); | |
// 初回のカードにボタンを追加するのじゃ | |
addSearchButtonToCard(initialCard, settings); | |
// その後の変更を監視するのじゃ | |
const historyObserver = new MutationObserver((mutations) => { | |
mutations.forEach((mutation) => { | |
if (mutation.type === "childList") { | |
mutation.addedNodes.forEach((node) => { | |
if (node.classList?.contains("itemModule") && node.classList.contains("list")) { | |
addSearchButtonToCard(node, settings); | |
} | |
}); | |
} | |
}); | |
}); | |
// 視聴履歴のコンテナを監視するのじゃ | |
const historyContainer = document.querySelector(".itemWrapper.clearfix"); | |
if (historyContainer) { | |
historyObserver.observe(historyContainer, { | |
childList: true, | |
subtree: true, | |
}); | |
} | |
// すでに表示されている他のカードにもボタンを追加するのじゃ | |
const existingCards = document.querySelectorAll(".itemModule.list"); | |
existingCards.forEach((card) => addSearchButtonToCard(card, settings)); | |
} catch (error) { | |
console.error("視聴履歴の監視開始でエラーが発生したのじゃ...", error); | |
showNotification("視聴履歴の監視開始に失敗したのじゃ...", "error"); | |
} | |
} | |
// 個別のカードにボタンを追加する関数を分離するのじゃ | |
function addSearchButtonToCard(card, settings) { | |
// すでにボタンが追加されている場合はスキップ | |
if (card.querySelector(".search-nico-comment")) return; | |
const title = card.querySelector(".line1")?.textContent?.trim() || ""; | |
const number = card.querySelector(".number.line1")?.textContent?.trim() || ""; | |
const episode = card.querySelector(".episode.line1")?.textContent?.trim() || ""; | |
// 検索ボタンを作成 | |
const searchButton = document.createElement("button"); | |
searchButton.className = "search-nico-comment"; | |
searchButton.textContent = "コメント自動設定"; | |
// ボタンのスタイルを設定 | |
searchButton.style.cssText = ` | |
position: absolute; | |
right: 72px; | |
bottom: 0px; | |
padding: 5px 10px; | |
background: #2196F3; | |
color: white; | |
border: none; | |
border-radius: 4px; | |
cursor: pointer; | |
font-size: 9px; | |
z-index: 10; | |
`; | |
// ホバー時のスタイル | |
searchButton.addEventListener("mouseover", () => { | |
searchButton.style.background = "#1976D2"; | |
}); | |
searchButton.addEventListener("mouseout", () => { | |
searchButton.style.background = "#2196F3"; | |
}); | |
// クリックイベントを設定 | |
searchButton.addEventListener("click", async () => { | |
await autoSearchAndSetVideo(title, number, episode, settings); | |
}); | |
// カードの相対位置を設定 | |
if (card.style.position !== "relative") { | |
card.style.position = "relative"; | |
} | |
// ボタンを追加 | |
card.appendChild(searchButton); | |
} | |
// 説明文から次の動画IDを抽出する関数を修正するのじゃ | |
function getNextVideoIdFromDescription(description) { | |
if (!description) return null; | |
// 2つのパターンを抽出するのじゃ | |
// 1. watch/数字 | |
// 2. watch/[a-z]{2}数字 | |
const numberPattern = /watch\/(\d+)/g; | |
const prefixPattern = /watch\/([a-z]{2}\d+)/g; | |
const videoIds = []; | |
// 数字のみのパターンを処理するのじゃ | |
const numberMatches = description.match(numberPattern); | |
if (numberMatches) { | |
numberMatches.forEach((match) => { | |
const id = match.split("/")[1]; | |
videoIds.push({ | |
id: id, // プレフィックスは付けないのじゃ | |
numericId: parseInt(id), | |
}); | |
}); | |
} | |
// プレフィックス付きのパターンを処理するのじゃ | |
const prefixMatches = description.match(prefixPattern); | |
if (prefixMatches) { | |
prefixMatches.forEach((match) => { | |
const id = match.split("/")[1]; | |
// プレフィックスと数字部分を分離するのじゃ | |
const prefix = id.substring(0, 2); | |
const numericPart = id.substring(2); | |
videoIds.push({ | |
id: id, // 元のIDをそのまま使うのじゃ | |
numericId: parseInt(numericPart), | |
prefix: prefix, | |
}); | |
}); | |
} | |
if (videoIds.length === 0) return null; | |
// プレフィックス付きのIDを優先的に処理するのじゃ | |
const prefixedIds = videoIds.filter((v) => v.prefix); | |
if (prefixedIds.length > 0) { | |
// プレフィックスごとにグループ化して、それぞれの中で最新のものを見つけるのじゃ | |
const groupedByPrefix = {}; | |
videoIds.forEach((v) => { | |
if (!groupedByPrefix[v.prefix] || v.numericId > groupedByPrefix[v.prefix].numericId) { | |
groupedByPrefix[v.prefix] = v; | |
} | |
}); | |
// soプレフィックスがあればそれを優先するのじゃ | |
if (groupedByPrefix["so"]) { | |
return groupedByPrefix["so"].id; | |
} | |
// なければ最も数字の大きいものを返すのじゃ | |
return Object.values(groupedByPrefix).reduce((max, current) => | |
current.numericId > max.numericId ? current : max | |
).id; | |
} | |
// プレフィックス付きのIDがない場合は、数字のみのIDから最大のものを返すのじゃ | |
return videoIds.reduce((max, current) => (current.numericId > max.numericId ? current : max)).id; | |
} | |
// observeVideoChanges関数を修正するのじゃ | |
function observeVideoChanges(videoElement, renderer, settings, fetcher, initialApiData) { | |
// コメント表示が無効になった場合はプリロードを行わないのじゃ | |
if (!settings.isEnabled) { | |
return { | |
videoObserver: null, | |
currentApiData: null, | |
cleanup: () => {}, | |
}; | |
} | |
let isPreloading = false; | |
let hasPreloaded = false; | |
let nextVideoData = null; | |
let currentApiData = initialApiData; | |
let previousSrc = videoElement.src; | |
// 残り時間を定期的にチェックするのじゃ | |
const timeCheckInterval = setInterval(() => { | |
const currentTime = videoElement.currentTime; | |
const duration = videoElement.duration; | |
if (duration - currentTime < 30 && !isPreloading && !hasPreloaded) { | |
// シリーズ情報がない場合は説明文から次の動画を探すのじゃ | |
if (!currentApiData?.series?.video?.next) { | |
const nextVideoId = getNextVideoIdFromDescription(currentApiData?.apiData?.video?.description); | |
if (nextVideoId) { | |
isPreloading = true; | |
preloadNextVideoData(nextVideoId, fetcher) | |
.then((data) => { | |
nextVideoData = data; | |
hasPreloaded = true; | |
showNotification( | |
"説明文から次の動画のデータを準備したのじゃ!\n" + `次の動画ID: ${nextVideoId}`, | |
"success" | |
); | |
}) | |
.catch((error) => { | |
console.error("次の動画のデータ取得に失敗したのじゃ...", error); | |
showNotification( | |
"次の動画のデータ取得に失敗したのじゃ...\n" + | |
"次の動画に切り替わった時は、マイページから手動でコメントを設定し直してほしいのじゃ!", | |
"error", | |
30000 | |
); | |
}) | |
.finally(() => { | |
isPreloading = false; | |
}); | |
} else { | |
// 説明文からも次の動画が見つからない場合の警告 | |
showNotification( | |
"この動画にはシリーズ情報も説明文の動画リンクも無いのじゃ...\n" + | |
"次の動画に切り替わった時は、マイページから手動でコメントを設定し直してほしいのじゃ!", | |
"warning", | |
30000 | |
); | |
hasPreloaded = true; | |
} | |
return; | |
} | |
isPreloading = true; | |
preloadNextVideoData(currentApiData.series.video.next.id, fetcher) | |
.then((data) => { | |
nextVideoData = data; | |
hasPreloaded = true; | |
showNotification("次の動画のデータを準備したのじゃ!", "success"); | |
}) | |
.catch((error) => { | |
console.error("次の動画のデータ取得に失敗したのじゃ...", error); | |
showNotification( | |
"次の動画のデータ取得に失敗したのじゃ...\n" + | |
"次の動画に切り替わった時は、マイページから手動でコメントを設定し直してほしいのじゃ!", | |
"error", | |
30000 | |
); | |
}) | |
.finally(() => { | |
isPreloading = false; | |
}); | |
} | |
}, 1000); | |
const videoObserver = new MutationObserver(async (mutations) => { | |
for (const mutation of mutations) { | |
if (mutation.type === "attributes" && mutation.attributeName === "src") { | |
const currentSrc = videoElement.src; | |
if (currentSrc && currentSrc !== previousSrc) { | |
if (nextVideoData) { | |
await switchToNextVideo(nextVideoData, renderer, settings); | |
currentApiData = nextVideoData.apiData; | |
nextVideoData = null; | |
hasPreloaded = false; // 次の動画に切り替わったらフラグをリセットするのじゃ | |
} | |
} | |
previousSrc = currentSrc; | |
} | |
} | |
}); | |
videoObserver.observe(videoElement, { | |
attributes: true, | |
attributeFilter: ["src"], | |
}); | |
return { | |
videoObserver, | |
currentApiData, | |
cleanup: () => { | |
clearInterval(timeCheckInterval); | |
videoObserver.disconnect(); | |
}, | |
}; | |
} | |
// 次の動画のデータを事前に読み込む関数なのじゃ | |
async function preloadNextVideoData(videoId, fetcher) { | |
const apiData = await fetcher.getApiData(videoId); | |
const comments = await fetcher.getComments(apiData); | |
return { | |
apiData, | |
comments, | |
videoId, | |
}; | |
} | |
// 次の動画に切り替える関数なのじゃ | |
async function switchToNextVideo(nextVideoData, renderer, settings) { | |
// 最終フェーズ関連の状態をリセット | |
renderer.isFinalPhase = false; | |
renderer.finalPhaseStartTime = 0; | |
renderer.finalComments = []; | |
// 既存の通知処理 | |
await showNotification( | |
"動画が切り替わったのを検知したのじゃ!\n新しいコメントを読み込むのじゃ...", | |
"info", | |
30000 | |
); | |
// 古いコメント数を記録 | |
const oldCommentCount = renderer.comments.length; | |
// コメントをクリア | |
renderer.comments = []; | |
// ここを修正するのじゃ! | |
const videoTitle = nextVideoData.apiData.videoTitle || "タイトル不明"; | |
const mainThreads = nextVideoData.comments.data.threads | |
.filter((thread) => thread.fork === "main") | |
.sort((a, b) => (b.comments?.length || 0) - (a.comments?.length || 0)); | |
if (mainThreads.length > 0 && mainThreads[0].comments?.length > 0) { | |
const newComments = mainThreads[0].comments; | |
newComments.forEach((comment) => { | |
renderer.addComment({ | |
vposMs: comment.vposMs, | |
body: comment.body, | |
}); | |
}); | |
// 詳細な通知を表示(タイトル付き) | |
await showNotification( | |
`コメントの切り替えが完了したのじゃ!\n` + | |
`タイトル: ${videoTitle}\n` + | |
`前の動画: ${oldCommentCount}件\n` + | |
`新しい動画: ${newComments.length}件のコメント\n` + | |
`動画ID: ${nextVideoData.videoId}`, | |
"success", | |
30000 | |
); | |
} else { | |
await showNotification( | |
`新しい動画にはコメントが見つからなかったのじゃ...\n` + | |
`タイトル: ${videoTitle}\n` + | |
`前の動画のコメント(${oldCommentCount}件)を削除したのじゃ。\n` + | |
`動画ID: ${nextVideoData.videoId}`, | |
"warning", | |
30000 | |
); | |
} | |
settings.lastVideoId = nextVideoData.videoId; | |
settings.lastVideoTitle = videoTitle; // タイトルも保存しておくのじゃ | |
} | |
// メイン処理の更新部分なのじゃ | |
(async function () { | |
const currentUrl = window.location.href; | |
const settings = new CommentSettings(); | |
// マイページの場合 | |
if (currentUrl.includes("/mp_viw_pc")) { | |
try { | |
const header = await waitForElement(".p-mypageHeader__title"); | |
const settingsUI = createSettingsUI(); | |
header.parentElement.insertBefore(settingsUI, header.nextSibling); | |
// 視聴履歴の監視を開始するのじゃ(settingsを渡す) | |
observeHistoryChanges(settings); | |
await initializeSettingsUI(settings, settingsUI); | |
} catch (error) { | |
console.error("設定UI初期化でエラーが発生したのじゃ...", error); | |
await showNotification("設定UI初期化でエラーが発生したのじゃ...", "error"); | |
} | |
} | |
// 視聴ページの場合 | |
else if (currentUrl.includes("/sc_d_pc")) { | |
try { | |
// コメント表示が無効な場合は早期リターンするのじゃ | |
if (!settings.isEnabled) { | |
await showNotification( | |
"コメント表示が無効になっているのじゃ。\nマイページで有効にすると表示されるのじゃ!", | |
"info" | |
); | |
return; | |
} | |
const videoElement = await waitForVideo(); | |
const renderer = new CommentRenderer(settings); | |
renderer.videoElement = videoElement; | |
// まず初期化を実行 | |
renderer.initialize(); | |
// その後で保存された設定を適用 | |
renderer.opacity = settings.opacity; | |
renderer.defaultColor = settings.defaultColor; | |
if (renderer.canvas) { | |
renderer.canvas.style.display = settings.isEnabled ? "block" : "none"; | |
} else { | |
console.error("キャンバスの初期化に失敗したのじゃ..."); | |
await showNotification("キャンバスの初期化に失敗したのじゃ...", "error"); | |
return; | |
} | |
window.commentRenderer = renderer; | |
const fetcher = new NicoCommentFetcher(); | |
let initialApiData = null; // 初期APIデータを保持する変数を追加 | |
// 最初のコメント読み込み処理を実行するのじゃ | |
if (settings.lastVideoId) { | |
try { | |
initialApiData = await fetcher.getApiData(settings.lastVideoId); | |
const comments = await fetcher.getComments(initialApiData); | |
// メインスレッドのコメントを取得して表示するのじゃ | |
const mainThreads = comments.data.threads | |
.filter((thread) => thread.fork === "main") | |
.sort((a, b) => (b.comments?.length || 0) - (a.comments?.length || 0)); | |
if (mainThreads.length > 0 && mainThreads[0].comments?.length > 0) { | |
mainThreads[0].comments.forEach((comment) => { | |
renderer.addComment({ | |
vposMs: comment.vposMs, | |
body: comment.body, | |
}); | |
}); | |
await showNotification( | |
`${mainThreads[0].comments.length}件のコメントを読み込んだのじゃ!\n | |
動画タイトル:${initialApiData.videoTitle}`, | |
"success" | |
); | |
// 動画の切り替わり監視を開始するのじゃ(初期APIデータを渡す) | |
const { videoObserver } = observeVideoChanges( | |
videoElement, | |
renderer, | |
settings, | |
fetcher, | |
initialApiData | |
); | |
// グローバルに保存して後で参照できるようにするのじゃ | |
window.nicoCommentData = { | |
videoObserver, | |
currentApiData: initialApiData, | |
fetcher, | |
renderer, | |
}; | |
} else { | |
await showNotification("コメントが見つからなかったのじゃ...", "warning"); | |
} | |
} catch (error) { | |
console.error("コメント読み込みでエラーが発生したのじゃ...", error); | |
await showNotification("コメント読み込みに失敗したのじゃ...", "error"); | |
} | |
} else { | |
await showNotification( | |
"動画IDが設定されていないのじゃ。マイページで設定してほしいのじゃ!", | |
"warning" | |
); | |
} | |
} catch (error) { | |
console.error("初期化中にエラーが発生したのじゃ...", error); | |
await showNotification("初期化中にエラーが発生したのじゃ...", "error"); | |
} | |
// キーボードショートカットを追加 | |
document.addEventListener("keydown", (e) => { | |
if (e.shiftKey && e.key.toLowerCase() === "c") { | |
if (window.commentRenderer) { | |
window.commentRenderer.toggleCommentVisibility(); | |
} | |
} | |
}); | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
使い方
1.Rawボタンを押してTampermonkeyにインストールする。
2.dアニメのマイページ(視聴履歴ページ)に行く。
3.動画を検索したりして動画IDを設定する。
4.動画を見る。