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 && (
-
-
字幕一覧
-
-
-
- | 番号 |
- 開始 |
- 終了 |
- Actor |
- テキスト |
-
-
-
- {cues.map((cue) => (
- handleJump(cue)}
- className={styles.tableRow}
- >
- | {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 (
+
+
字幕一覧
+
+
+
+ | 番号 |
+ 開始 |
+ 終了 |
+ Actor |
+ テキスト |
+
+
+
+ {currentSubtitleCues.data.map((cue) => (
+ handleJump(cue)}
+ className={styles.tableRow}
+ >
+ | {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