From a9c5ccdbb871d67be7a9177485a80a62e1df82f5 Mon Sep 17 00:00:00 2001 From: Sakamoto Shiina <68018796+ShiinaSakamoto@users.noreply.github.com> Date: Sat, 15 Feb 2025 08:46:15 +0900 Subject: [PATCH] [Update/Refactor] Subtitle system v1.1. Organize file system and change the design. --- src-ui/app/App.jsx | 4 + .../SubtitleSystemContainer.jsx | 485 +----------------- .../SubtitleSystemContainer.module.scss | 145 ++---- .../_controllers/SubtitlesController.jsx | 39 ++ .../_logics/useSubtitles.jsx | 193 +++++++ .../_subtitles_utils.js | 112 ++++ .../CountdownContainer.jsx | 72 +++ .../CountdownContainer.module.scss | 47 ++ .../InputFileContainer.jsx | 62 +++ .../InputFileContainer.module.scss | 42 ++ .../ModeSelectorContainer.jsx | 74 +++ .../ModeSelectorContainer.module.scss | 50 ++ .../PlayControlContainer.jsx | 33 ++ .../PlayControlContainer.module.scss | 31 ++ .../SubtitlesListContainer.jsx | 45 ++ .../SubtitlesListContainer.module.scss | 0 src-ui/store.js | 20 +- 17 files changed, 895 insertions(+), 559 deletions(-) create mode 100644 src-ui/app/main_page/main_section/subtitle_system_container/_controllers/SubtitlesController.jsx create mode 100644 src-ui/app/main_page/main_section/subtitle_system_container/_logics/useSubtitles.jsx create mode 100644 src-ui/app/main_page/main_section/subtitle_system_container/_subtitles_utils.js create mode 100644 src-ui/app/main_page/main_section/subtitle_system_container/countdown_container/CountdownContainer.jsx create mode 100644 src-ui/app/main_page/main_section/subtitle_system_container/countdown_container/CountdownContainer.module.scss create mode 100644 src-ui/app/main_page/main_section/subtitle_system_container/input_file_container/InputFileContainer.jsx create mode 100644 src-ui/app/main_page/main_section/subtitle_system_container/input_file_container/InputFileContainer.module.scss create mode 100644 src-ui/app/main_page/main_section/subtitle_system_container/mode_selector_container/ModeSelectorContainer.jsx create mode 100644 src-ui/app/main_page/main_section/subtitle_system_container/mode_selector_container/ModeSelectorContainer.module.scss create mode 100644 src-ui/app/main_page/main_section/subtitle_system_container/play_control_container/PlayControlContainer.jsx create mode 100644 src-ui/app/main_page/main_section/subtitle_system_container/play_control_container/PlayControlContainer.module.scss create mode 100644 src-ui/app/main_page/main_section/subtitle_system_container/subtitles_list_container/SubtitlesListContainer.jsx create mode 100644 src-ui/app/main_page/main_section/subtitle_system_container/subtitles_list_container/SubtitlesListContainer.module.scss diff --git a/src-ui/app/App.jsx b/src-ui/app/App.jsx index 86b5430d..03fa2f8c 100644 --- a/src-ui/app/App.jsx +++ b/src-ui/app/App.jsx @@ -21,6 +21,8 @@ import { SnackbarController } from "./snackbar_controller/SnackbarController"; import styles from "./App.module.scss"; import { useIsBackendReady, useIsSoftwareUpdating, useIsVrctAvailable, useWindow } from "@logics_common"; +import { SubtitlesController } from "./main_page/main_section/subtitle_system_container/_controllers/subtitlesController.jsx"; + export const App = () => { const { currentIsVrctAvailable } = useIsVrctAvailable(); const { currentIsBackendReady } = useIsBackendReady(); @@ -53,6 +55,8 @@ const Contents = () => { const { currentIsSoftwareUpdating } = useIsSoftwareUpdating(); return ( <> + + {currentIsSoftwareUpdating.data === false ? diff --git a/src-ui/app/main_page/main_section/subtitle_system_container/SubtitleSystemContainer.jsx b/src-ui/app/main_page/main_section/subtitle_system_container/SubtitleSystemContainer.jsx index 17e1dc82..144aefb0 100644 --- a/src-ui/app/main_page/main_section/subtitle_system_container/SubtitleSystemContainer.jsx +++ b/src-ui/app/main_page/main_section/subtitle_system_container/SubtitleSystemContainer.jsx @@ -1,476 +1,45 @@ -import React, { useState, useRef, useEffect } from "react"; import styles from "./SubtitleSystemContainer.module.scss"; -import { useSendTextToOverlay } from "@logics_configs"; +import { InputFileContainer } from "./input_file_container/InputFileContainer"; +import { ModeSelectorContainer } from "./mode_selector_container/ModeSelectorContainer"; +import { PlayControlContainer } from "./play_control_container/PlayControlContainer"; +import { CountdownContainer } from "./countdown_container/CountdownContainer"; +import { SubtitlesListContainer } from "./subtitles_list_container/SubtitlesListContainer"; export const SubtitleSystemContainer = () => { - const { sendTextToOverlay } = useSendTextToOverlay(); - const [srtContent, setSrtContent] = useState(""); - const [cues, setCues] = useState([]); - const [isPlaying, setIsPlaying] = useState(false); + // const [srtContent, setSrtContent] = useState(""); + // const [cues, setCues] = useState([]); + // const [isPlaying, setIsPlaying] = useState(false); // 再生モード ("relative": ボタン押下から、"absolute": 指定時刻から) - const [playbackMode, setPlaybackMode] = useState("relative"); + // const [playbackMode, setPlaybackMode] = useState("relative"); // 絶対モード用の再生開始時刻(ドロップダウンで選択、HH:MM) - const [targetHour, setTargetHour] = useState("23"); - const [targetMinute, setTargetMinute] = useState("00"); + // const [targetHour, setTargetHour] = useState("23"); + // const [targetMinute, setTargetMinute] = useState("00"); // カウントダウン状態 - // initialCountdown: 再生開始ボタン押下時に算出される元の残り秒数 - const [initialCountdown, setInitialCountdown] = useState(null); + // // initialCountdown: 再生開始ボタン押下時に算出される元の残り秒数 + // const [initialCountdown, setInitialCountdown] = useState(null); // countdownAdjustment: ユーザーが上下ボタンで調整する値(秒単位) - const [countdownAdjustment, setCountdownAdjustment] = useState(0); + // const [countdownAdjustment, setCountdownAdjustment] = useState(0); // effectiveCountdown: (initialCountdown + countdownAdjustment) から経過秒数を差し引いた表示用の値 - const [effectiveCountdown, setEffectiveCountdown] = useState(null); + // const [effectiveCountdown, setEffectiveCountdown] = useState(null); // cuesScheduled: 字幕タイマーが一度スケジュールされたか - const [cuesScheduled, setCuesScheduled] = useState(false); + // const [cuesScheduled, setCuesScheduled] = useState(false); - // タイマー(setTimeout/setInterval)のID管理用 - const timersRef = useRef([]); - // カウントダウンタイマー専用の ref - const countdownIntervalRef = useRef(null); - // ファイル入力リセット用の 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 = []; - if (countdownIntervalRef.current) { - clearInterval(countdownIntervalRef.current); - countdownIntervalRef.current = null; - } - console.log("再生を停止しました。"); - setIsPlaying(false); - setInitialCountdown(null); - setEffectiveCountdown(null); - setCountdownAdjustment(0); - setCuesScheduled(false); - }; - - // cues のスケジュールを行う(字幕開始時のオフセットは調整後のタイミングに合わせる) - 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); - } - }); - }; - - // カウントダウンタイマーの開始/再登録(指定した値から1秒ごとに減らす) - const startCountdownInterval = (startValue) => { - // 既存のタイマーがあればクリア - if (countdownIntervalRef.current) { - clearInterval(countdownIntervalRef.current); - } - // 新たな開始値を設定 - setEffectiveCountdown(startValue); - countdownIntervalRef.current = setInterval(() => { - setEffectiveCountdown((prev) => { - if (prev <= 1) { - clearInterval(countdownIntervalRef.current); - return 0; - } - return prev - 1; - }); - }, 1000); - timersRef.current.push(countdownIntervalRef.current); - }; - - // 「再生開始」ボタン押下時の処理 - 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); - // 調整値を反映した開始値 - const startValue = computedCountdown + countdownAdjustment; - startCountdownInterval(startValue); - sendTextToOverlay(startValue.toString()); - }; - - // effectiveCountdown が 0 になったとき、字幕開始 - useEffect(() => { - if ( - isPlaying && - effectiveCountdown !== null && - effectiveCountdown <= 0 && - !cuesScheduled - ) { - sendTextToOverlay("Start."); - console.log("Start."); - // 調整後のタイミングで字幕スケジュールを開始 - scheduleCues(0); - setCuesScheduled(true); - } - - console.log(secToDayTime(effectiveCountdown)); - sendTextToOverlay(secToDayTime(effectiveCountdown)); - }, [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([]); - }; + // // タイマー(setTimeout/setInterval)のID管理用 + // const timersRef = useRef([]); + // // カウントダウンタイマー専用の ref + // const countdownIntervalRef = useRef(null); return (
-

字幕プレイヤー

-
- - -
-
- - {playbackMode === "absolute" && ( -
- -
- - : - -
-
- )} -
-
- - -
- {/* カウントダウン表示:字幕開始前は常に表示 */} - {effectiveCountdown !== null && !cuesScheduled && ( -
- カウントダウン: {secToDayTime(effectiveCountdown)} -
- {/* 1分単位の調整ボタン */} -
- - -
- {/* 1秒単位の調整ボタン */} -
- - -
-
-
- )} - {/* 字幕一覧の表示(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; -}; - -const secToDayTime = (seconds) => { - const day = Math.floor(seconds / 86400); - const hour = Math.floor((seconds % 86400) / 3600); - const min = Math.floor((seconds % 3600) / 60); - const sec = seconds % 60; - let time = ""; - // day が 0 の場合は「日」は出力しない(hour や min も同様) - if (day !== 0) { - time = `${day}日${hour}時間${min}分${sec}秒`; - } else if (hour !== 0) { - time = `${hour}:${min}:${sec}`; - } else if (min !== 0) { - time = `${String(min).padStart(2, "0")}:${String(sec).padStart(2, "0")}`; - } else { - time = `${String(sec).padStart(2, "0")}`; - } - return time; -}; diff --git a/src-ui/app/main_page/main_section/subtitle_system_container/SubtitleSystemContainer.module.scss b/src-ui/app/main_page/main_section/subtitle_system_container/SubtitleSystemContainer.module.scss index ca0bc25d..ef618213 100644 --- a/src-ui/app/main_page/main_section/subtitle_system_container/SubtitleSystemContainer.module.scss +++ b/src-ui/app/main_page/main_section/subtitle_system_container/SubtitleSystemContainer.module.scss @@ -1,7 +1,6 @@ .container { - padding: 4rem; - background: #1e1e1e; - color: #ffffff; + padding: 2rem 4rem; + background: var(--dark_900_color); border-radius: 1rem; flex-shrink: 0; height: 100%; @@ -9,48 +8,54 @@ display: flex; flex-direction: column; gap: 2rem; +} - h1 { - font-size: 2.4rem; - margin-bottom: 1.5rem; - text-align: center; - } +.title { + font-size: 1.8rem; + text-align: center; + flex-shrink: 0; +} - label { - display: block; - font-size: 1.6rem; - margin-bottom: 0.5rem; - } +.border { + width: 100%; + height: 0.2rem; + background-color: var(--dark_800_color); + flex-shrink: 0; - input, - select { - font-size: 1.6rem; - padding: 0.5rem; - border-radius: 0.5rem; - border: 0.1rem solid #ccc; - background: #333; - color: #fff; - } +} + + // 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; + // 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); - } - } + // &:focus { + // outline: none; + // box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); + // } + // } // 再生開始用ボタン(通常時) .primary { @@ -70,14 +75,7 @@ } } - // ファイルクリア用ボタン - .file_clear { - background: #6c757d; - color: #fff; - &:hover { - background: #5a6268; - } - } + // 「再生中」状態(クリック不可)用のスタイル .is_playing { @@ -86,41 +84,6 @@ pointer-events: none; } - // カウントダウン表示用エリア - .countdown { - margin-top: 1rem; - font-size: 2.6rem; - display: flex; - align-items: center; - gap: 1rem; - flex-direction: column; - - span { - font-weight: bold; - } - - button { - padding: 0.5rem 1rem; - font-size: 1.6rem; - 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 { @@ -154,26 +117,8 @@ } } } - } - } + .subtitle_lists { font-size: 1.4rem; } - -.time_section { - display: flex; - gap: 2rem; - justify-content: center; - align-items: center; -} -.time_selects_item { - gap: 0.4rem; - width: 6rem; - text-align: center; -} - -.adjust_button_wrapper { - display: flex; - gap: 2rem; -} \ No newline at end of file diff --git a/src-ui/app/main_page/main_section/subtitle_system_container/_controllers/SubtitlesController.jsx b/src-ui/app/main_page/main_section/subtitle_system_container/_controllers/SubtitlesController.jsx new file mode 100644 index 00000000..720c3d28 --- /dev/null +++ b/src-ui/app/main_page/main_section/subtitle_system_container/_controllers/SubtitlesController.jsx @@ -0,0 +1,39 @@ +import { useSendTextToOverlay } from "@logics_configs"; +import { useSubtitles } from "../_logics/useSubtitles"; +import { secToDayTime } from "../_subtitles_utils" +import { useEffect } from "react"; +export const SubtitlesController = () => { + const { sendTextToOverlay } = useSendTextToOverlay(); + const { + currentIsSubtitlePlaying, + currentIsCuesScheduled, + updateIsCuesScheduled, + currentCountdownAdjustment, + currentEffectiveCountdown, + scheduleCues, + } = useSubtitles(); + + // currentEffectiveCountdown.data が 0 になったとき、字幕開始 + useEffect(() => { + if ( + currentIsSubtitlePlaying.data && + currentEffectiveCountdown.data !== null && + currentEffectiveCountdown.data <= 0 && + !currentIsCuesScheduled.data + ) { + sendTextToOverlay("スタート!"); + console.log("スタート!"); + // 調整後のタイミングで字幕スケジュールを開始 + scheduleCues(0); + updateIsCuesScheduled(true); + } + + if (currentEffectiveCountdown.data > 0) { + console.log(secToDayTime(currentEffectiveCountdown.data)); + sendTextToOverlay(secToDayTime(currentEffectiveCountdown.data)); + } + + }, [currentEffectiveCountdown.data, currentIsSubtitlePlaying.data, currentIsCuesScheduled.data, currentCountdownAdjustment.data]); + + return null; +}; \ No newline at end of file diff --git a/src-ui/app/main_page/main_section/subtitle_system_container/_logics/useSubtitles.jsx b/src-ui/app/main_page/main_section/subtitle_system_container/_logics/useSubtitles.jsx new file mode 100644 index 00000000..9ce1d626 --- /dev/null +++ b/src-ui/app/main_page/main_section/subtitle_system_container/_logics/useSubtitles.jsx @@ -0,0 +1,193 @@ +import { useSendTextToOverlay } from "@logics_configs"; +import { + useStore_SubtitleFileName, + useStore_IsSubtitlePlaying, + useStore_SubtitlePlaybackMode, + useStore_SubtitleAbsoluteTargetTime, + useStore_IsCuesScheduled, + useStore_CountdownAdjustment, + useStore_EffectiveCountdown, + useStore_SubtitleCues, + + useStore_SubtitleTimers, + useStore_SubtitleCountdownTimerId, +} from "@store"; + +export const useSubtitles = () => { + const { sendTextToOverlay } = useSendTextToOverlay(); + const { currentSubtitleFileName, updateSubtitleFileName } = useStore_SubtitleFileName(); + const { currentIsSubtitlePlaying, updateIsSubtitlePlaying } = useStore_IsSubtitlePlaying(); + const { currentSubtitlePlaybackMode, updateSubtitlePlaybackMode } = useStore_SubtitlePlaybackMode(); + const { currentSubtitleAbsoluteTargetTime, updateSubtitleAbsoluteTargetTime } = useStore_SubtitleAbsoluteTargetTime(); + const { currentIsCuesScheduled, updateIsCuesScheduled } = useStore_IsCuesScheduled(); + + const { currentCountdownAdjustment, updateCountdownAdjustment } = useStore_CountdownAdjustment(); + const { currentEffectiveCountdown, updateEffectiveCountdown } = useStore_EffectiveCountdown(); + const { currentSubtitleCues, updateSubtitleCues } = useStore_SubtitleCues(); + + // タイマー(setTimeout/setInterval)のID管理用 + const { currentSubtitleTimers, updateSubtitleTimers, addSubtitleTimers } = useStore_SubtitleTimers(); + // const timersRef = useRef([]); + // カウントダウンタイマー専用の ref + const { currentSubtitleCountdownTimerId, updateSubtitleCountdownTimerId, AddSubtitleCountdownTimerId } = useStore_SubtitleCountdownTimerId(); + + // cues のスケジュールを行う(字幕開始時のオフセットは調整後のタイミングに合わせる) + const scheduleCues = (offset) => { + // 字幕開始時の処理 + 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(""); + }; + + currentSubtitleCues.data.forEach((cue) => { + const startDelay = cue.startTime * 1000 + offset; + const endDelay = cue.endTime * 1000 + offset; + if (startDelay >= 0) { + const timerId = setTimeout(() => startFunction(cue), startDelay); + addSubtitleTimers(timerId); + } + if (endDelay >= 0) { + const timerId = setTimeout(() => endFunction(cue), endDelay); + addSubtitleTimers(timerId); + } + }); + }; + + + // カウントダウンタイマーの開始/再登録(指定した値から1秒ごとに減らす) + const startCountdownInterval = (startValue) => { + // 既存のタイマーがあればクリア + if (currentSubtitleCountdownTimerId.data) { + clearInterval(currentSubtitleCountdownTimerId.data); + } + // 新たな開始値を設定 + updateEffectiveCountdown(startValue); + const countdown_timer_id = setInterval(() => { + updateEffectiveCountdown((prev) => { + if (prev.data <= 1) { + clearInterval(currentSubtitleCountdownTimerId.data); + return 0; + } + return prev.data - 1; + }); + }, 1000); + updateSubtitleCountdownTimerId(countdown_timer_id); + addSubtitleTimers(currentSubtitleCountdownTimerId.data); + }; + + + // 字幕一覧の表示(relative モードの場合、クリックでジャンプ) + // テーブル内の字幕行をクリック(relative モードのみ)でジャンプ + const handleJump = (jumpCue) => { + if (currentSubtitlePlaybackMode.data !== "relative") return; + handleSubtitlesStop(); + const offset = -jumpCue.startTime * 1000; + scheduleCues(offset); + updateIsSubtitlePlaying(true); + }; + + + + // 「再生開始」ボタン押下時の処理 + const handleSubtitlesStart = () => { + handleSubtitlesStop(); + updateIsSubtitlePlaying(true); + updateIsCuesScheduled(false); + const target_time = currentSubtitleAbsoluteTargetTime.data; + + let computedCountdown = 0; + if (currentSubtitlePlaybackMode.data === "absolute") { + const now = new Date(); + const hour = parseInt(target_time.hour, 10); + const minute = parseInt(target_time.minute, 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); + // 調整値を反映した開始値 + const startValue = computedCountdown + currentCountdownAdjustment.data; + startCountdownInterval(startValue); + sendTextToOverlay(startValue.toString()); + }; + + + // すべてのタイマーを停止し、各状態を初期化する + const handleSubtitlesStop = () => { + currentSubtitleTimers.data.forEach((timerId) => { + clearTimeout(timerId); + clearInterval(timerId); + }); + + updateSubtitleTimers([]); + if (currentSubtitleCountdownTimerId.data) { + clearInterval(currentSubtitleCountdownTimerId.data); + updateSubtitleCountdownTimerId(null); + } + console.log("再生を停止しました。"); + updateIsSubtitlePlaying(false); + // setInitialCountdown(null); + updateEffectiveCountdown(null); + updateCountdownAdjustment(0); + updateIsCuesScheduled(false); + }; + + + return { + currentSubtitleFileName, + updateSubtitleFileName, + + currentIsSubtitlePlaying, + updateIsSubtitlePlaying, + + currentSubtitlePlaybackMode, + updateSubtitlePlaybackMode, + + currentSubtitleAbsoluteTargetTime, + updateSubtitleAbsoluteTargetTime, + + currentIsCuesScheduled, + updateIsCuesScheduled, + + currentCountdownAdjustment, + updateCountdownAdjustment, + + currentEffectiveCountdown, + updateEffectiveCountdown, + + currentSubtitleCues, + updateSubtitleCues, + + handleSubtitlesStart, + handleSubtitlesStop, + startCountdownInterval, + scheduleCues, + handleJump, + } + +}; \ No newline at end of file diff --git a/src-ui/app/main_page/main_section/subtitle_system_container/_subtitles_utils.js b/src-ui/app/main_page/main_section/subtitle_system_container/_subtitles_utils.js new file mode 100644 index 00000000..8bed1f7b --- /dev/null +++ b/src-ui/app/main_page/main_section/subtitle_system_container/_subtitles_utils.js @@ -0,0 +1,112 @@ + +/** + * SRT形式の文字列を解析する関数 + * 改行コードを正規化し、空行で分割して解析する + * (actor は存在しないため、空文字列をセット) + */ +export 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 として抽出する。 + */ +export 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 秒 + */ +export 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 時刻文字列を秒数に変換する関数 + */ +export 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; +}; + +const padTime = (int) => { + return String(int).padStart(2, "0"); +}; + +export const secToDayTime = (seconds) => { + const day = Math.floor(seconds / 86400); + const hour = Math.floor((seconds % 86400) / 3600); + const min = Math.floor((seconds % 3600) / 60); + const sec = seconds % 60; + let time = ""; + // day が 0 の場合は「日」は出力しない(hour や min も同様) + if (day !== 0) { + time = `${day}日${hour}時間${min}分${sec}秒`; + } else if (hour !== 0) { + time = `${padTime(hour)}:${padTime(min)}:${padTime(sec)}`; + } else { + time = `${padTime(min)}:${padTime(sec)}`; + } + // } else { + // time = `${padTime(sec)}`; + // } + return time; +}; + + +// HH:MM:SS 形式に変換する補助関数 +export 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") + ); +}; diff --git a/src-ui/app/main_page/main_section/subtitle_system_container/countdown_container/CountdownContainer.jsx b/src-ui/app/main_page/main_section/subtitle_system_container/countdown_container/CountdownContainer.jsx new file mode 100644 index 00000000..419db219 --- /dev/null +++ b/src-ui/app/main_page/main_section/subtitle_system_container/countdown_container/CountdownContainer.jsx @@ -0,0 +1,72 @@ +import React, { useState, useRef, useEffect } from "react"; +import styles from "./CountdownContainer.module.scss"; +import { secToDayTime } from "../_subtitles_utils"; +import { useSubtitles } from "../_logics/useSubtitles"; + +export const CountdownContainer = () => { + const { + updateCountdownAdjustment, + currentEffectiveCountdown, + currentIsCuesScheduled, + startCountdownInterval, + } = useSubtitles(); + // カウントダウン表示:字幕開始前は常に表示 + + // if (currentEffectiveCountdown.data === 0) return null; + if (currentEffectiveCountdown.data === null && currentIsCuesScheduled.data) return null; + + return ( +
+ カウントダウン: {secToDayTime(currentEffectiveCountdown.data)} +
+ {/* 1分単位の調整ボタン */} +
+ + +
+
+ {/* 1秒単位の調整ボタン */} +
+ + +
+
+
+ ); +}; \ No newline at end of file diff --git a/src-ui/app/main_page/main_section/subtitle_system_container/countdown_container/CountdownContainer.module.scss b/src-ui/app/main_page/main_section/subtitle_system_container/countdown_container/CountdownContainer.module.scss new file mode 100644 index 00000000..0ad5a7ff --- /dev/null +++ b/src-ui/app/main_page/main_section/subtitle_system_container/countdown_container/CountdownContainer.module.scss @@ -0,0 +1,47 @@ +.container { + margin-top: 1rem; + font-size: 2.6rem; + display: flex; + align-items: center; + gap: 1rem; + flex-direction: column; + + span { + font-weight: bold; + } + +} + +.adjust_button_container { + display: flex; + gap: 10rem; +} + +.adjust_button_wrapper { + display: flex; + flex-direction: column; + gap: 4rem; +} + + + +.adjust_button { + padding: 1rem 1.4rem; + font-size: 1.8rem; + border-radius: 0.4rem; + background: var(--primary_600_color); + color: #fff; + cursor: pointer; + + &:hover { + background: var(--primary_400_color); + } + &:active { + background: var(--primary_650_color); + } +} + +.adjust_button_border { + background-color: var(--dark_600_color); + width: 0.2rem; +} \ No newline at end of file diff --git a/src-ui/app/main_page/main_section/subtitle_system_container/input_file_container/InputFileContainer.jsx b/src-ui/app/main_page/main_section/subtitle_system_container/input_file_container/InputFileContainer.jsx new file mode 100644 index 00000000..1e234765 --- /dev/null +++ b/src-ui/app/main_page/main_section/subtitle_system_container/input_file_container/InputFileContainer.jsx @@ -0,0 +1,62 @@ +import React, { useState, useRef, useEffect } from "react"; +import styles from "./InputFileContainer.module.scss"; +import { useSubtitles } from "../_logics/useSubtitles"; +import { parseSRT, parseASS } from "../_subtitles_utils"; + +export const InputFileContainer = () => { + const { + updateSubtitleFileName, + currentSubtitleFileName, + updateSubtitleCues, + handleSubtitlesStop + } = useSubtitles(); + + // ファイルアップロード時の処理 + const handleFileUpload = (event) => { + const file = event.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (e) => { + const content = e.target.result; + let parsedCues = []; + // 拡張子により ASS と SRT を判定 + if (file.name.toLowerCase().endsWith(".ass")) { + parsedCues = parseASS(content); + } else { + parsedCues = parseSRT(content); + } + updateSubtitleCues(parsedCues); + console.log("Parsed cues:", parsedCues); + updateSubtitleFileName(file.name); + + }; + reader.readAsText(file); + }; + + + // ファイルクリア + const handleClearFile = () => { + handleSubtitlesStop(); + updateSubtitleFileName("ファイルが選択されていません"); + updateSubtitleCues([]); + }; + + return ( +
+
+ + +

{currentSubtitleFileName.data}

+
+ +
+ ); +}; \ No newline at end of file diff --git a/src-ui/app/main_page/main_section/subtitle_system_container/input_file_container/InputFileContainer.module.scss b/src-ui/app/main_page/main_section/subtitle_system_container/input_file_container/InputFileContainer.module.scss new file mode 100644 index 00000000..91966cbb --- /dev/null +++ b/src-ui/app/main_page/main_section/subtitle_system_container/input_file_container/InputFileContainer.module.scss @@ -0,0 +1,42 @@ +.container { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6rem; +} + +.input_file_wrapper { + display: flex; + align-items: center; + gap: 1rem; +} +.input_file_label { + font-size: 1.4rem; + border-radius: 0.4rem; + padding: 1rem; + background-color: var(--dark_850_color); + border: 0.1rem solid var(--dark_400_color); + flex-shrink: 0; +} +.input_file_i { + display: none; +} +.file_name { + font-size: 1.6rem; + padding: 0.5rem; + width: 100%; + max-width: 60rem; +} + +.file_clear { + background: var(--dark_800_color); + color: var(--dark_200_color); + border-radius: 0.4rem; + font-size: 1.2rem; + padding: 0.6rem 1rem; + cursor: pointer; + + &:hover { + background: var(--error_bc_color); + } +} \ No newline at end of file diff --git a/src-ui/app/main_page/main_section/subtitle_system_container/mode_selector_container/ModeSelectorContainer.jsx b/src-ui/app/main_page/main_section/subtitle_system_container/mode_selector_container/ModeSelectorContainer.jsx new file mode 100644 index 00000000..e19ddba2 --- /dev/null +++ b/src-ui/app/main_page/main_section/subtitle_system_container/mode_selector_container/ModeSelectorContainer.jsx @@ -0,0 +1,74 @@ +import styles from "./ModeSelectorContainer.module.scss"; +import { useSubtitles } from "../_logics/useSubtitles"; +export const ModeSelectorContainer = () => { + const { + currentSubtitlePlaybackMode, + updateSubtitlePlaybackMode, + currentSubtitleAbsoluteTargetTime, + updateSubtitleAbsoluteTargetTime, + } = useSubtitles(); + + const target_time = currentSubtitleAbsoluteTargetTime.data; + + const handleOnchangeTargetTime = (key, value) => { + updateSubtitleAbsoluteTargetTime((old_value) => { + return { + ...old_value.data, + [key]: value, + } + }); + }; + + + return ( +
+
+ +
+ + {currentSubtitlePlaybackMode.data === "absolute" && ( +
+ +
+ + : + +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/src-ui/app/main_page/main_section/subtitle_system_container/mode_selector_container/ModeSelectorContainer.module.scss b/src-ui/app/main_page/main_section/subtitle_system_container/mode_selector_container/ModeSelectorContainer.module.scss new file mode 100644 index 00000000..a3c805d9 --- /dev/null +++ b/src-ui/app/main_page/main_section/subtitle_system_container/mode_selector_container/ModeSelectorContainer.module.scss @@ -0,0 +1,50 @@ +.container { + // background-color: red; + display: flex; + gap: 4rem; +} +.mode_selector_wrapper { + display: flex; + align-items: center; + gap: 2rem; +} +.absolute_time_label { + font-size: 1.4rem; +} + +.mode_selector { + font-size: 1.6rem; + padding: 0.6rem 1rem; + border-radius: 0.5rem; + border: 0.1rem solid var(--dark_400_color); + cursor: pointer; +} + +.mode_selector_item { + background-color: var(--dark_800_color); +} + + +.time_section { + display: flex; + gap: 2rem; + justify-content: center; + align-items: center; +} + +.time_selects { + display: flex; + align-items: center; + gap: 0.5rem; +} + + +.time_selects_item { + width: 6rem; + text-align: center; + padding: 0.6rem 1rem; + font-size: 1.8rem; + background-color: var(--dark_850_color); + border: 0.1rem solid var(--dark_400_color); + cursor: pointer; +} \ No newline at end of file diff --git a/src-ui/app/main_page/main_section/subtitle_system_container/play_control_container/PlayControlContainer.jsx b/src-ui/app/main_page/main_section/subtitle_system_container/play_control_container/PlayControlContainer.jsx new file mode 100644 index 00000000..0e4fdbeb --- /dev/null +++ b/src-ui/app/main_page/main_section/subtitle_system_container/play_control_container/PlayControlContainer.jsx @@ -0,0 +1,33 @@ +// import React, { useState, useRef, useEffect } from "react"; +import styles from "./PlayControlContainer.module.scss"; +import { useSubtitles } from "../_logics/useSubtitles"; +import { clsx } from "clsx"; + +export const PlayControlContainer = () => { + const { + currentIsSubtitlePlaying, + handleSubtitlesStart, + handleSubtitlesStop, + } = useSubtitles(); + + const is_playing = currentIsSubtitlePlaying.data; + + const playback_button_classname = clsx(styles.playback_button, { + [styles.is_playing]: is_playing, + }); + return ( +
+ + {is_playing && + + } +
+ ); +}; \ No newline at end of file diff --git a/src-ui/app/main_page/main_section/subtitle_system_container/play_control_container/PlayControlContainer.module.scss b/src-ui/app/main_page/main_section/subtitle_system_container/play_control_container/PlayControlContainer.module.scss new file mode 100644 index 00000000..ea5de457 --- /dev/null +++ b/src-ui/app/main_page/main_section/subtitle_system_container/play_control_container/PlayControlContainer.module.scss @@ -0,0 +1,31 @@ +.container { + display: flex; + gap: 4rem; + display: flex; + justify-content: center; +} + +.playback_button, .playback_stop_button { + font-size: 1.6rem; + padding: 1rem 2rem; + cursor: pointer; + border-radius: 0.4rem; +} + +.playback_button { + background-color: var(--primary_550_color); + &:hover { + background-color: var(--primary_400_color); + } + &.is_playing { + background-color: var(--primary_650_color); + pointer-events: none; + color: var(--dark_400_color); + } +} +.playback_stop_button { + background-color: var(--dark_800_color); + &:hover { + background-color: var(--error_bc_color); + } +} \ No newline at end of file diff --git a/src-ui/app/main_page/main_section/subtitle_system_container/subtitles_list_container/SubtitlesListContainer.jsx b/src-ui/app/main_page/main_section/subtitle_system_container/subtitles_list_container/SubtitlesListContainer.jsx new file mode 100644 index 00000000..b997870e --- /dev/null +++ b/src-ui/app/main_page/main_section/subtitle_system_container/subtitles_list_container/SubtitlesListContainer.jsx @@ -0,0 +1,45 @@ +import React, { useState, useRef, useEffect } from "react"; +import styles from "./SubtitlesListContainer.module.scss"; +import { useSubtitles } from "../_logics/useSubtitles"; +import { formatTime } from "../_subtitles_utils"; + +export const SubtitlesListContainer = () => { + const { currentSubtitleCues, handleJump } = useSubtitles(); + + if (currentSubtitleCues.data.length < 0 ) return null; + + return ( +
+

字幕一覧

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

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

+
+ ); +}; \ No newline at end of file diff --git a/src-ui/app/main_page/main_section/subtitle_system_container/subtitles_list_container/SubtitlesListContainer.module.scss b/src-ui/app/main_page/main_section/subtitle_system_container/subtitles_list_container/SubtitlesListContainer.module.scss new file mode 100644 index 00000000..e69de29b diff --git a/src-ui/store.js b/src-ui/store.js index 17c74d20..dede547b 100644 --- a/src-ui/store.js +++ b/src-ui/store.js @@ -284,4 +284,22 @@ export const { atomInstance: Atom_IsOpenedTranslatorSelector, useHook: useStore_ export const { atomInstance: Atom_SupportersData, useHook: useStore_SupportersData } = createAtomWithHook(null, "SupportersData", {is_state_ok: true}); export const { atomInstance: Atom_VrctPosterIndex, useHook: useStore_VrctPosterIndex } = createAtomWithHook(0, "VrctPosterIndex"); -export const { atomInstance: Atom_PosterShowcaseWorldPageIndex, useHook: useStore_PosterShowcaseWorldPageIndex } = createAtomWithHook(0, "PosterShowcaseWorldPageIndex"); \ No newline at end of file +export const { atomInstance: Atom_PosterShowcaseWorldPageIndex, useHook: useStore_PosterShowcaseWorldPageIndex } = createAtomWithHook(0, "PosterShowcaseWorldPageIndex"); + +// ----------------------------------------------- +// Subtitles +// ----------------------------------------------- +export const { atomInstance: Atom_IsSubtitlePlaying, useHook: useStore_IsSubtitlePlaying } = createAtomWithHook(false, "IsSubtitlePlaying", { is_state_ok: true }); +export const { atomInstance: Atom_SubtitlePlaybackMode, useHook: useStore_SubtitlePlaybackMode } = createAtomWithHook("relative", "SubtitlePlaybackMode", { is_state_ok: true }); +export const { atomInstance: Atom_SubtitleAbsoluteTargetTime, useHook: useStore_SubtitleAbsoluteTargetTime } = createAtomWithHook({ + hour: "23", + minute: "00", +}, "SubtitleAbsoluteTargetTime", { is_state_ok: true }); +export const { atomInstance: Atom_IsCuesScheduled, useHook: useStore_IsCuesScheduled } = createAtomWithHook(false, "IsCuesScheduled", { is_state_ok: true }); +export const { atomInstance: Atom_CountdownAdjustment, useHook: useStore_CountdownAdjustment } = createAtomWithHook(0, "CountdownAdjustment", { is_state_ok: true }); +export const { atomInstance: Atom_EffectiveCountdown, useHook: useStore_EffectiveCountdown } = createAtomWithHook(null, "EffectiveCountdown", { is_state_ok: true }); +export const { atomInstance: Atom_SubtitleCues, useHook: useStore_SubtitleCues } = createAtomWithHook([], "SubtitleCues", { is_state_ok: true }); + +export const { atomInstance: Atom_SubtitleTimers, useHook: useStore_SubtitleTimers } = createAtomWithHook([], "SubtitleTimers", { is_state_ok: true }); +export const { atomInstance: Atom_SubtitleCountdownTimerId, useHook: useStore_SubtitleCountdownTimerId } = createAtomWithHook([], "SubtitleCountdownTimerId", { is_state_ok: true }); +export const { atomInstance: Atom_SubtitleFileName, useHook: useStore_SubtitleFileName } = createAtomWithHook("ファイルが選択されていません", "SubtitleFileName", { is_state_ok: true }); \ No newline at end of file