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 (

字幕プレイヤー

{playbackMode === "absolute" && (
:
)}
{/* カウントダウン表示:字幕開始前は常に表示 */} {effectiveCountdown !== null && !cuesScheduled && (
カウントダウン: {effectiveCountdown} 秒
)} {/* 字幕一覧の表示(relative モードの場合、クリックでジャンプ) */} {cues.length > 0 && (

字幕一覧

{cues.map((cue) => ( handleJump(cue)} className={styles.tableRow} > ))}
番号 開始 終了 Actor テキスト
{cue.index} {formatTime(cue.startTime)} {formatTime(cue.endTime)} {cue.actor} {cue.text}

※ 行をクリックすると、その字幕の位置にジャンプします。(相対モードのみ)

)}
); }; /** * 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; };