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; + } +}