Files
VRCT/src-ui/app/main_page/plugins_section/PluginsSection.jsx

411 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useRef, useEffect } from "react";
import styles from "./PluginsSection.module.scss";
import { useSendTextToOverlay } from "@logics_configs";
export const PluginsSection = () => {
const { sendTextToOverlay } = useSendTextToOverlay();
const [srtContent, setSrtContent] = useState("");
const [cues, setCues] = useState([]);
const [isPlaying, setIsPlaying] = useState(false);
// 再生モード ("relative": ボタン押下から、"absolute": 指定時刻から)
const [playbackMode, setPlaybackMode] = useState("relative");
// 絶対モード用の再生開始時刻ドロップダウンで選択、HH:MM
const [targetHour, setTargetHour] = useState("19");
const [targetMinute, setTargetMinute] = useState("00");
// カウントダウン状態
// initialCountdown: 再生開始ボタン押下時に算出される元の残り秒数
const [initialCountdown, setInitialCountdown] = useState(null);
// countdownAdjustment: ユーザーが上下ボタンで調整する値(秒単位)
const [countdownAdjustment, setCountdownAdjustment] = useState(0);
// effectiveCountdown: (initialCountdown + countdownAdjustment) から経過秒数を差し引いた表示用の値
const [effectiveCountdown, setEffectiveCountdown] = useState(null);
// cuesScheduled: 字幕タイマーが一度スケジュールされたか
const [cuesScheduled, setCuesScheduled] = useState(false);
// タイマーsetTimeout/setIntervalのID管理用
const timersRef = useRef([]);
// ファイル入力リセット用の ref
const fileInputRef = useRef(null);
// ファイルアップロード時の処理
const handleFileUpload = (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target.result;
setSrtContent(content);
let parsedCues = [];
// 拡張子により ASS と SRT を判定
if (file.name.toLowerCase().endsWith(".ass")) {
parsedCues = parseASS(content);
} else {
parsedCues = parseSRT(content);
}
setCues(parsedCues);
console.log("Parsed cues:", parsedCues);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
reader.readAsText(file);
};
// 字幕開始時の処理
const startFunction = (cue) => {
let send_text = "";
if (cue.actor !== "") {
send_text = `[${cue.actor}] ${cue.text}`;
} else {
send_text = `${cue.text}`;
}
console.log(`字幕開始 (index: ${cue.index}) send_text:${send_text}`);
sendTextToOverlay(send_text);
};
// 字幕終了時の処理
const endFunction = (cue) => {
console.log(`字幕終了 (index: ${cue.index}): ${cue.text}`);
// 必要に応じた終了処理(例:テキストクリア)を実装可能
// sendTextToOverlay("");
};
// すべてのタイマーを停止し、各状態を初期化する
const handleStop = () => {
timersRef.current.forEach((timerId) => {
clearTimeout(timerId);
clearInterval(timerId);
});
timersRef.current = [];
console.log("再生を停止しました。");
setIsPlaying(false);
setInitialCountdown(null);
setEffectiveCountdown(null);
setCountdownAdjustment(0);
setCuesScheduled(false);
};
// cues のスケジュールを行う(字幕開始時のオフセットは countdownAdjustment * 1000
const scheduleCues = (offset) => {
cues.forEach((cue) => {
const startDelay = cue.startTime * 1000 + offset;
const endDelay = cue.endTime * 1000 + offset;
if (startDelay >= 0) {
const timerId = setTimeout(() => startFunction(cue), startDelay);
timersRef.current.push(timerId);
}
if (endDelay >= 0) {
const timerId = setTimeout(() => endFunction(cue), endDelay);
timersRef.current.push(timerId);
}
});
};
// カウントダウンタイマーの開始
const startCountdownInterval = (initialValue) => {
setEffectiveCountdown(initialValue + countdownAdjustment);
const countdownInterval = setInterval(() => {
setEffectiveCountdown((prev) => {
if (prev <= 1) {
clearInterval(countdownInterval);
return 0;
}
return prev - 1;
});
}, 1000);
timersRef.current.push(countdownInterval);
};
// 「再生開始」ボタン押下時の処理
const handleStart = () => {
handleStop();
setIsPlaying(true);
setCuesScheduled(false);
let computedCountdown = 0;
if (playbackMode === "absolute") {
const now = new Date();
const hour = parseInt(targetHour, 10);
const minute = parseInt(targetMinute, 10);
let targetDate = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
hour,
minute,
0,
0
);
if (targetDate.getTime() < now.getTime()) {
targetDate.setDate(targetDate.getDate() + 1);
}
computedCountdown = Math.ceil((targetDate.getTime() - now.getTime()) / 1000);
} else {
computedCountdown = 10; // relative モードの場合は固定値
}
setInitialCountdown(computedCountdown);
setEffectiveCountdown(computedCountdown + countdownAdjustment);
sendTextToOverlay((computedCountdown + countdownAdjustment).toString());
startCountdownInterval(computedCountdown);
};
// effectiveCountdown が 0 になったとき、字幕開始
useEffect(() => {
if (
isPlaying &&
effectiveCountdown !== null &&
effectiveCountdown <= 0 &&
!cuesScheduled
) {
sendTextToOverlay("Start.");
console.log("Start.");
// オフセットは countdownAdjustment × 1000 を字幕に反映
scheduleCues(countdownAdjustment * 1000);
setCuesScheduled(true);
}
}, [effectiveCountdown, isPlaying, cuesScheduled, countdownAdjustment]);
// テーブル内の字幕行をクリックrelative モードのみ)でジャンプ
const handleJump = (jumpCue) => {
if (playbackMode !== "relative") return;
handleStop();
const offset = -jumpCue.startTime * 1000;
scheduleCues(offset);
setIsPlaying(true);
};
// HH:MM:SS 形式に変換する補助関数
const formatTime = (timeInSeconds) => {
const hours = Math.floor(timeInSeconds / 3600);
const minutes = Math.floor((timeInSeconds % 3600) / 60);
const seconds = Math.floor(timeInSeconds % 60);
return (
String(hours).padStart(2, "0") +
":" +
String(minutes).padStart(2, "0") +
":" +
String(seconds).padStart(2, "0")
);
};
// ファイルクリア
const handleClearFile = () => {
handleStop();
setSrtContent("");
setCues([]);
};
return (
<div className={styles.container}>
<h1>字幕プレイヤー</h1>
<div className={styles.fileSection}>
<label className={styles.label}>
SRT/ASSファイルを選択:
<input
ref={fileInputRef}
type="file"
accept=".srt,.ass"
onChange={handleFileUpload}
className={styles.input}
/>
</label>
<button onClick={handleClearFile} className={styles.fileClear}>
ファイルクリア
</button>
</div>
<div className={styles.modeSection}>
<label className={styles.label}>
再生モード:
<select
value={playbackMode}
onChange={(e) => setPlaybackMode(e.target.value)}
className={styles.select}
>
<option value="relative">相対ボタン押下から</option>
<option value="absolute">絶対指定時刻から</option>
</select>
</label>
{playbackMode === "absolute" && (
<div className={styles.timeSection}>
<label className={styles.label}>再生開始時刻 (HH:MM):</label>
<div className={styles.timeSelects}>
<select
value={targetHour}
onChange={(e) => setTargetHour(e.target.value)}
className={styles.select}
>
{Array.from({ length: 24 }, (_, i) => {
const hour = i.toString().padStart(2, "0");
return (
<option key={i} value={hour}>
{hour}
</option>
);
})}
</select>
<span>:</span>
<select
value={targetMinute}
onChange={(e) => setTargetMinute(e.target.value)}
className={styles.select}
>
{Array.from({ length: 60 }, (_, i) => {
const minute = i.toString().padStart(2, "0");
return (
<option key={i} value={minute}>
{minute}
</option>
);
})}
</select>
</div>
</div>
)}
</div>
<div className={styles.controlSection}>
<button
onClick={handleStart}
className={isPlaying ? styles.is_playing : styles.primary}
>
{isPlaying ? "再生中" : "再生開始"}
</button>
<button onClick={handleStop} className={styles.secondary}>
停止
</button>
</div>
{/* カウントダウン表示:字幕開始前は常に表示 */}
{effectiveCountdown !== null && !cuesScheduled && (
<div className={styles.countdown}>
<span>カウントダウン: {effectiveCountdown} </span>
<button
onClick={() =>
setEffectiveCountdown((prev) => prev + 1)
}
className={styles.adjustButton}
>
</button>
<button
onClick={() =>
setEffectiveCountdown((prev) => prev - 1)
}
className={styles.adjustButton}
>
</button>
</div>
)}
{/* 字幕一覧の表示relative モードの場合、クリックでジャンプ) */}
{cues.length > 0 && (
<div className={styles.subtitleSection}>
<h2>字幕一覧</h2>
<table className={styles.table}>
<thead>
<tr>
<th>番号</th>
<th>開始</th>
<th>終了</th>
<th>Actor</th>
<th>テキスト</th>
</tr>
</thead>
<tbody className={styles.subtitle_lists}>
{cues.map((cue) => (
<tr
key={cue.index}
onClick={() => handleJump(cue)}
className={styles.tableRow}
>
<td>{cue.index}</td>
<td>{formatTime(cue.startTime)}</td>
<td>{formatTime(cue.endTime)}</td>
<td>{cue.actor}</td>
<td>{cue.text}</td>
</tr>
))}
</tbody>
</table>
<p className={styles.note}>
行をクリックするとその字幕の位置にジャンプします相対モードのみ
</p>
</div>
)}
</div>
);
};
/**
* SRT形式の文字列を解析する関数
* 改行コードを正規化し、空行で分割して解析する
* actor は存在しないため、空文字列をセット)
*/
const parseSRT = (data) => {
const cues = [];
const normalizedData = data.replace(/\r\n/g, "\n").trim();
const blocks = normalizedData.split(/\n\s*\n/);
blocks.forEach((block) => {
const lines = block.split("\n").filter((line) => line.trim() !== "");
if (lines.length >= 3) {
const index = parseInt(lines[0], 10);
const timeMatch = lines[1].match(/([\d:,]+)\s+-->\s+([\d:,]+)/);
if (!timeMatch) return;
const start = parseTime(timeMatch[1]);
const end = parseTime(timeMatch[2]);
const text = lines.slice(2).join("\n");
cues.push({ index, startTime: start, endTime: end, actor: "", text });
}
});
return cues;
};
/**
* ASS形式の文字列を解析する関数
* [Events] セクション内の "Dialogue:" 行から、
* フォーマット "Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"
* に沿って分割する。
* ここでは Name を actor、Text を text として抽出する。
*/
const parseASS = (data) => {
const cues = [];
const lines = data.split(/\r?\n/);
let index = 1;
lines.forEach((line) => {
if (line.startsWith("Dialogue:")) {
const dialogueLine = line.substring("Dialogue:".length).trim();
const parts = dialogueLine.split(",");
// parts[0]: Layer, parts[1]: Start, parts[2]: End, parts[3]: Style, parts[4]: Name, parts[5]: MarginL, parts[6]: MarginR, parts[7]: MarginV, parts[8]: Effect, parts[9]: Text
if (parts.length < 10) return;
const startTime = parseASSTime(parts[1].trim());
const endTime = parseASSTime(parts[2].trim());
const actor = parts[4].trim();
const text = parts.slice(9).join(",").trim();
cues.push({ index: index++, startTime, endTime, actor, text });
}
});
return cues;
};
/**
* "H:MM:SS.cc" 形式の ASS 時刻文字列を秒数に変換する関数
* 例: "0:00:10.52" → 10.52 秒
*/
const parseASSTime = (timeString) => {
const parts = timeString.split(":");
if (parts.length !== 3) return 0;
const hours = parseFloat(parts[0]);
const minutes = parseFloat(parts[1]);
const seconds = parseFloat(parts[2]);
return hours * 3600 + minutes * 60 + seconds;
};
/**
* "HH:MM:SS,mmm" 形式の SRT 時刻文字列を秒数に変換する関数
*/
const parseTime = (timeString) => {
const [hms, ms] = timeString.split(",");
const [hours, minutes, seconds] = hms.split(":").map(Number);
return hours * 3600 + minutes * 60 + seconds + Number(ms) / 1000;
};