diff --git a/src-ui/app/main_page/MainPage.jsx b/src-ui/app/main_page/MainPage.jsx
index a83ef09a..324bc463 100644
--- a/src-ui/app/main_page/MainPage.jsx
+++ b/src-ui/app/main_page/MainPage.jsx
@@ -2,6 +2,7 @@ import clsx from "clsx";
import styles from "./MainPage.module.scss";
import { SidebarSection } from "./sidebar_section/SidebarSection";
import { MainSection } from "./main_section/MainSection";
+import { PluginsSection } from "./plugins_section/PluginsSection";
import { useIsOpenedConfigPage } from "@logics_common";
export const MainPage = () => {
@@ -15,6 +16,7 @@ export const MainPage = () => {
);
diff --git a/src-ui/app/main_page/plugins_section/PluginsSection.jsx b/src-ui/app/main_page/plugins_section/PluginsSection.jsx
new file mode 100644
index 00000000..7925c4d5
--- /dev/null
+++ b/src-ui/app/main_page/plugins_section/PluginsSection.jsx
@@ -0,0 +1,349 @@
+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);
+ const parsedCues = parseSRT(content);
+ setCues(parsedCues);
+ console.log("パース結果:", parsedCues);
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ };
+ reader.readAsText(file);
+ };
+
+ // 字幕開始時の処理
+ const startFunction = (cue) => {
+ console.log(`字幕開始 (index: ${cue.index}): ${cue.text}`);
+ sendTextToOverlay(cue.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 のスケジュールを行う(offset は 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) => {
+ // 初期表示は (initialValue + countdownAdjustment)
+ 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.");
+ scheduleCues(0);
+ 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} 秒
+
+
+
+ {cues.length > 0 && (
+
+
字幕一覧
+
+
+
+ | 番号 |
+ 開始 |
+ 終了 |
+ テキスト |
+
+
+
+ {cues.map((cue) => (
+ handleJump(cue)}
+ className={styles.tableRow}
+ >
+ | {cue.index} |
+ {formatTime(cue.startTime)} |
+ {formatTime(cue.endTime)} |
+ {cue.text} |
+
+ ))}
+
+
+
+ ※ 行をクリックすると、その字幕の位置にジャンプします。(相対モードのみ)
+
+
+ )}
+
+ );
+};
+
+/**
+ * SRT形式の文字列を解析する関数
+ * ユーザー提示のサンプルに基づき、改行コードを正規化後、空行で分割して解析
+ */
+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, text });
+ }
+ });
+ return cues;
+};
+
+/**
+ * "HH:MM:SS,mmm" 形式の文字列を秒数に変換する関数
+ */
+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;
+};
diff --git a/src-ui/app/main_page/plugins_section/PluginsSection.module.scss b/src-ui/app/main_page/plugins_section/PluginsSection.module.scss
new file mode 100644
index 00000000..c6a1a6c7
--- /dev/null
+++ b/src-ui/app/main_page/plugins_section/PluginsSection.module.scss
@@ -0,0 +1,159 @@
+.container {
+ padding: 2rem;
+ background: #1e1e1e;
+ color: #ffffff;
+ border-radius: 1rem;
+ max-width: 60rem;
+ margin: 0 auto;
+ flex-shrink: 0;
+ height: 100%;
+ overflow: auto;
+
+ h1 {
+ font-size: 2.4rem;
+ margin-bottom: 1.5rem;
+ text-align: center;
+ }
+
+ label {
+ display: block;
+ font-size: 1.6rem;
+ margin-bottom: 0.5rem;
+ }
+
+ input,
+ select {
+ font-size: 1.6rem;
+ padding: 0.5rem;
+ border-radius: 0.5rem;
+ border: 0.1rem solid #ccc;
+ background: #333;
+ color: #fff;
+ }
+
+ input[type="file"] {
+ padding: 0.5rem;
+ }
+
+ // ボタンの基本スタイル
+ button {
+ font-size: 1.8rem;
+ padding: 1rem 2rem;
+ border: none;
+ border-radius: 0.5rem;
+ cursor: pointer;
+ transition: background 0.3s;
+ margin-right: 1rem;
+
+ &:focus {
+ outline: none;
+ box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
+ }
+ }
+
+ // 再生開始用ボタン(通常時)
+ .primary {
+ background: #007bff;
+ color: #fff;
+ &:hover {
+ background: #0056b3;
+ }
+ }
+
+ // 再生停止用ボタン
+ .secondary {
+ background: #dc3545;
+ color: #fff;
+ &:hover {
+ background: #a71d2a;
+ }
+ }
+
+ // ファイルクリア用ボタン
+ .file-clear {
+ background: #6c757d;
+ color: #fff;
+ &:hover {
+ background: #5a6268;
+ }
+ }
+
+ // 「再生中」状態(クリック不可)用のスタイル
+ .is_playing {
+ background: #6c757d;
+ cursor: not-allowed;
+ }
+
+ // カウントダウン表示用エリア
+ .countdown {
+ margin-top: 1rem;
+ font-size: 1.6rem;
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+
+ span {
+ font-weight: bold;
+ }
+
+ button {
+ font-size: 1.6rem;
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: 0.4rem;
+ background: #28a745;
+ color: #fff;
+ cursor: pointer;
+ transition: background 0.3s;
+
+ &:hover {
+ background: #218838;
+ }
+ }
+ }
+
+ // 再生モードや時刻指定の select 関連(横並びの場合などに調整)
+ .time-selects {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ }
+
+ // 字幕一覧のテーブル
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-top: 2rem;
+
+ th,
+ td {
+ padding: 1rem;
+ border: 0.1rem solid #444;
+ text-align: left;
+ font-size: 1.4rem;
+ }
+
+ th {
+ background: #555;
+ }
+
+ tbody {
+ tr {
+ cursor: pointer;
+ transition: background 0.2s;
+
+ &:nth-child(even) {
+ background: #2a2a2a;
+ }
+
+ &:hover {
+ background: #444;
+ }
+ }
+ }
+ }
+
+ .subtitle_lists {
+ font-size: 1.4rem;
+ }
+}