[Update/Refactor] Subtitle system v1.1. Organize file system and change the design.

This commit is contained in:
Sakamoto Shiina
2025-02-15 08:46:15 +09:00
parent ae62642945
commit a9c5ccdbb8
17 changed files with 895 additions and 559 deletions

View File

@@ -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 (
<>
<SubtitlesController />
<WindowTitleBar />
{currentIsSoftwareUpdating.data === false
?

View File

@@ -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 (
<div className={styles.container}>
<h1>字幕プレイヤー</h1>
<div className={styles.fileSection}>
<label className={styles.label}>
SRT/ASSファイルを選択:
<input
ref={fileInputRef}
type="file"
accept=".srt,.ass"
onChange={handleFileUpload}
className={styles.input}
/>
</label>
<button onClick={handleClearFile} className={styles.file_clear}>
ファイルクリア
</button>
</div>
<div className={styles.modeSection}>
<label className={styles.label}>
再生モード:
<select
value={playbackMode}
onChange={(e) => setPlaybackMode(e.target.value)}
className={styles.select}
>
<option value="relative">相対ボタン押下から</option>
<option value="absolute">絶対指定時刻から</option>
</select>
</label>
{playbackMode === "absolute" && (
<div className={styles.time_section}>
<label className={styles.label}>再生開始時刻 (HH:MM):</label>
<div className={styles.time_selects}>
<select
value={targetHour}
onChange={(e) => setTargetHour(e.target.value)}
className={styles.time_selects_item}
>
{Array.from({ length: 24 }, (_, i) => {
const hour = i.toString().padStart(2, "0");
return (
<option key={i} value={hour}>
{hour}
</option>
);
})}
</select>
<span>:</span>
<select
value={targetMinute}
onChange={(e) => setTargetMinute(e.target.value)}
className={styles.time_selects_item}
>
{Array.from({ length: 60 }, (_, i) => {
const minute = i.toString().padStart(2, "0");
return (
<option key={i} value={minute}>
{minute}
</option>
);
})}
</select>
</div>
</div>
)}
</div>
<div className={styles.controlSection}>
<button
onClick={handleStart}
className={isPlaying ? styles.is_playing : styles.primary}
>
{isPlaying ? "再生中" : "再生開始"}
</button>
<button onClick={handleStop} className={styles.secondary}>
停止
</button>
</div>
{/* カウントダウン表示:字幕開始前は常に表示 */}
{effectiveCountdown !== null && !cuesScheduled && (
<div className={styles.countdown}>
<span>カウントダウン: {secToDayTime(effectiveCountdown)}</span>
<div className={styles.adjust_button_wrapper}>
{/* 1分単位の調整ボタン */}
<div>
<button
onClick={() => {
const newValue = effectiveCountdown + 60;
setCountdownAdjustment((prev) => prev + 60);
startCountdownInterval(newValue);
}}
className={styles.adjust_button}
>
1
</button>
<button
onClick={() => {
const newValue = effectiveCountdown - 60;
setCountdownAdjustment((prev) => prev - 60);
startCountdownInterval(newValue);
}}
className={styles.adjust_button}
>
1
</button>
</div>
{/* 1秒単位の調整ボタン */}
<div>
<button
onClick={() => {
const newValue = effectiveCountdown + 1;
setCountdownAdjustment((prev) => prev + 1);
startCountdownInterval(newValue);
}}
className={styles.adjust_button}
>
1
</button>
<button
onClick={() => {
const newValue = effectiveCountdown - 1;
setCountdownAdjustment((prev) => prev - 1);
startCountdownInterval(newValue);
}}
className={styles.adjust_button}
>
1
</button>
</div>
</div>
</div>
)}
{/* 字幕一覧の表示relative モードの場合、クリックでジャンプ) */}
{cues.length > 0 && (
<div className={styles.subtitleSection}>
<h2>字幕一覧</h2>
<table className={styles.table}>
<thead>
<tr>
<th>番号</th>
<th>開始</th>
<th>終了</th>
<th>Actor</th>
<th>テキスト</th>
</tr>
</thead>
<tbody className={styles.subtitle_lists}>
{cues.map((cue) => (
<tr
key={cue.index}
onClick={() => handleJump(cue)}
className={styles.tableRow}
>
<td>{cue.index}</td>
<td>{formatTime(cue.startTime)}</td>
<td>{formatTime(cue.endTime)}</td>
<td>{cue.actor}</td>
<td>{cue.text}</td>
</tr>
))}
</tbody>
</table>
<p className={styles.note}>
行をクリックするとその字幕の位置にジャンプします相対モードのみ
</p>
</div>
)}
<h1 className={styles.title}>字幕プレイヤー</h1>
<InputFileContainer />
<ModeSelectorContainer />
<PlayControlContainer />
<div className={styles.border}></div>
<CountdownContainer />
<SubtitlesListContainer />
</div>
);
};
/**
* 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;
};

View File

@@ -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;
.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;
}

View File

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

View File

@@ -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,
}
};

View File

@@ -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")
);
};

View File

@@ -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 (
<div className={styles.container}>
<span>カウントダウン: {secToDayTime(currentEffectiveCountdown.data)}</span>
<div className={styles.adjust_button_container}>
{/* 1分単位の調整ボタン */}
<div className={styles.adjust_button_wrapper}>
<button
onClick={() => {
const newValue = currentEffectiveCountdown.data + 60;
updateCountdownAdjustment((prev) => prev.data + 60);
startCountdownInterval(newValue);
}}
className={styles.adjust_button}
>
1
</button>
<button
onClick={() => {
const newValue = currentEffectiveCountdown.data - 60;
updateCountdownAdjustment((prev) => prev.data - 60);
startCountdownInterval(newValue);
}}
className={styles.adjust_button}
>
1
</button>
</div>
<div className={styles.adjust_button_border}></div>
{/* 1秒単位の調整ボタン */}
<div className={styles.adjust_button_wrapper}>
<button
onClick={() => {
const newValue = currentEffectiveCountdown.data + 1;
updateCountdownAdjustment((prev) => prev.data + 1);
startCountdownInterval(newValue);
}}
className={styles.adjust_button}
>
1
</button>
<button
onClick={() => {
const newValue = currentEffectiveCountdown.data - 1;
updateCountdownAdjustment((prev) => prev.data - 1);
startCountdownInterval(newValue);
}}
className={styles.adjust_button}
>
1
</button>
</div>
</div>
</div>
);
};

View File

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

View File

@@ -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 (
<div className={styles.container}>
<div className={styles.input_file_wrapper}>
<label htmlFor="subtitle_file_input" className={styles.input_file_label}>SRT/ASSファイルを選択</label>
<input
id="subtitle_file_input"
type="file"
accept=".srt,.ass"
onChange={handleFileUpload}
className={styles.input_file_i}
/>
<p className={styles.file_name}>{currentSubtitleFileName.data}</p>
</div>
<button onClick={handleClearFile} className={styles.file_clear}>
ファイルクリア
</button>
</div>
);
};

View File

@@ -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);
}
}

View File

@@ -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 (
<div className={styles.container}>
<div className={styles.mode_selector_wrapper}>
<select
value={currentSubtitlePlaybackMode.data}
onChange={(e) => updateSubtitlePlaybackMode(e.target.value)}
className={styles.mode_selector}
>
<option className={styles.mode_selector_item} value="relative">相対モードボタン押下から</option>
<option className={styles.mode_selector_item} value="absolute">絶対モード指定時刻から</option>
</select>
</div>
{currentSubtitlePlaybackMode.data === "absolute" && (
<div className={styles.time_section}>
<label className={styles.absolute_time_label}>再生開始時刻 (HH:MM):</label>
<div className={styles.time_selects}>
<select
value={target_time.hour}
onChange={(e) => handleOnchangeTargetTime("hour", e.target.value)}
className={styles.time_selects_item}
>
{Array.from({ length: 24 }, (_, i) => {
const hour = i.toString().padStart(2, "0");
return (
<option key={i} value={hour}>
{hour}
</option>
);
})}
</select>
<span>:</span>
<select
value={target_time.minute}
onChange={(e) => handleOnchangeTargetTime("minute", e.target.value)}
className={styles.time_selects_item}
>
{Array.from({ length: 60 }, (_, i) => {
const minute = i.toString().padStart(2, "0");
return (
<option key={i} value={minute}>
{minute}
</option>
);
})}
</select>
</div>
</div>
)}
</div>
);
};

View File

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

View File

@@ -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 (
<div className={styles.container}>
<button
onClick={handleSubtitlesStart}
className={playback_button_classname}
>
{is_playing ? "再生中" : "字幕を登録・再生"}
</button>
{is_playing &&
<button onClick={handleSubtitlesStop} className={styles.playback_stop_button}>
停止
</button>
}
</div>
);
};

View File

@@ -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);
}
}

View File

@@ -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 (
<div className={styles.subtitleSection}>
<h2>字幕一覧</h2>
<table className={styles.table}>
<thead>
<tr>
<th>番号</th>
<th>開始</th>
<th>終了</th>
<th>Actor</th>
<th>テキスト</th>
</tr>
</thead>
<tbody className={styles.subtitle_lists}>
{currentSubtitleCues.data.map((cue) => (
<tr
key={cue.index}
onClick={() => handleJump(cue)}
className={styles.tableRow}
>
<td>{cue.index}</td>
<td>{formatTime(cue.startTime)}</td>
<td>{formatTime(cue.endTime)}</td>
<td>{cue.actor}</td>
<td>{cue.text}</td>
</tr>
))}
</tbody>
</table>
<p className={styles.note}>
行をクリックするとその字幕の位置にジャンプします相対モードのみ
</p>
</div>
);
};

View File

@@ -285,3 +285,21 @@ export const { atomInstance: Atom_SupportersData, useHook: useStore_SupportersDa
export const { atomInstance: Atom_VrctPosterIndex, useHook: useStore_VrctPosterIndex } = createAtomWithHook(0, "VrctPosterIndex");
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 });