Skip to content

Instantly share code, notes, and snippets.

@roflsunriz
Last active February 22, 2025 21:54
Show Gist options
  • Save roflsunriz/0aa936cf4c42d932e6b64185c83a4ecb to your computer and use it in GitHub Desktop.
Save roflsunriz/0aa936cf4c42d932e6b64185c83a4ecb to your computer and use it in GitHub Desktop.
dAnime NicoComment Renderer : dアニメでニコニコ動画のコメントをレンダリングするプログラム
// ==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();
}
}
});
}
})();
@roflsunriz
Copy link
Author

使い方
1.Rawボタンを押してTampermonkeyにインストールする。
2.dアニメのマイページ(視聴履歴ページ)に行く。
3.動画を検索したりして動画IDを設定する。
4.動画を見る。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment