fix: crash on playback start + support multiple recordings
Crash fix (React Hooks violation): - PlaybackTimeline.tsx: move ALL hooks (useRef for PanResponder, useCallback) to before the early return; use seekRef/totalRef to share values into PanResponder handlers without stale closure issues Multiple recordings support: - trainRecorder.ts: redesign to id-based multi-recording system - RecordingMeta type for lightweight list (no snapshots) - TrainRecording now includes id field - saveRecording/loadRecordingById/deleteRecordingById/loadRecordingList - migrateOldRecording() migrates old single MOCK_RECORDING key on first launch - constants/storage.ts: add MOCK_RECORDINGS_INDEX + MOCK_RECORDING_DATA_PREFIX keys - useTrainMenu: savedRecording → recordingList (RecordingMeta[]) + activeRecording (TrainRecording|null) - startPlayback(id) loads full recording on demand - deleteRecording(id) deletes by id and refreshes list - stopPlayback clears activeRecording - DataSourceSettings: recording list UI - shows all recordings with date/time, snapshot count, duration - ▶ play and 削除 buttons per row - recording/playing status indicator Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { FC, useRef, useState, useCallback } from "react";
|
||||
import React, { FC, useRef, useCallback } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -23,7 +23,7 @@ export const PlaybackTimeline: FC = () => {
|
||||
const { moderateScale } = useResponsive();
|
||||
const {
|
||||
recorderState,
|
||||
savedRecording,
|
||||
activeRecording,
|
||||
playbackIndex,
|
||||
playbackPaused,
|
||||
stopPlayback,
|
||||
@@ -32,54 +32,59 @@ export const PlaybackTimeline: FC = () => {
|
||||
seekToSnapshot,
|
||||
} = useTrainMenu();
|
||||
|
||||
const [trackWidth, setTrackWidth] = useState(1);
|
||||
// ─── すべてのフックは早期returnより前に呼ぶ ───
|
||||
const trackWidthRef = useRef(1);
|
||||
|
||||
if (recorderState !== "playing" || !savedRecording) return null;
|
||||
// PanResponder のハンドラ内で最新値を参照するために ref を使う
|
||||
const seekRef = useRef(seekToSnapshot);
|
||||
seekRef.current = seekToSnapshot;
|
||||
const totalRef = useRef(0);
|
||||
|
||||
const total = savedRecording.snapshots.length;
|
||||
const snap = savedRecording.snapshots[playbackIndex];
|
||||
|
||||
// スナップショット時刻 = 録画開始時刻 + elapsed
|
||||
const snapTime = dayjs(savedRecording.recordedAt).add(snap.t, "ms");
|
||||
const timeLabel = snapTime.format("HH:mm:ss");
|
||||
|
||||
// 録画の総時間をフォーマット
|
||||
const totalSec = Math.round(savedRecording.durationMs / 1000);
|
||||
const totalLabel =
|
||||
totalSec >= 60
|
||||
? `${Math.floor(totalSec / 60)}:${String(totalSec % 60).padStart(2, "0")}`
|
||||
: `${totalSec}s`;
|
||||
|
||||
const progress = total > 1 ? playbackIndex / (total - 1) : 0;
|
||||
|
||||
// スクラバーのドラッグ処理
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderGrant: (evt) => {
|
||||
const x = evt.nativeEvent.locationX;
|
||||
const idx = Math.round((x / trackWidthRef.current) * (total - 1));
|
||||
seekToSnapshot(Math.max(0, Math.min(idx, total - 1)));
|
||||
const idx = Math.round((x / trackWidthRef.current) * (totalRef.current - 1));
|
||||
seekRef.current(Math.max(0, Math.min(idx, totalRef.current - 1)));
|
||||
},
|
||||
onPanResponderMove: (evt) => {
|
||||
const x = evt.nativeEvent.locationX;
|
||||
const idx = Math.round((x / trackWidthRef.current) * (total - 1));
|
||||
seekToSnapshot(Math.max(0, Math.min(idx, total - 1)));
|
||||
const idx = Math.round((x / trackWidthRef.current) * (totalRef.current - 1));
|
||||
seekRef.current(Math.max(0, Math.min(idx, totalRef.current - 1)));
|
||||
},
|
||||
})
|
||||
).current;
|
||||
|
||||
const onTrackLayout = useCallback((e: LayoutChangeEvent) => {
|
||||
const w = e.nativeEvent.layout.width;
|
||||
setTrackWidth(w);
|
||||
trackWidthRef.current = w;
|
||||
trackWidthRef.current = e.nativeEvent.layout.width;
|
||||
}, []);
|
||||
|
||||
const btnSize = moderateScale(36);
|
||||
const iconSize = moderateScale(20);
|
||||
|
||||
// ─── 早期return ───
|
||||
if (recorderState !== "playing" || !activeRecording) return null;
|
||||
|
||||
const total = activeRecording.snapshots.length;
|
||||
totalRef.current = total; // PanResponder が参照する最新値を更新
|
||||
|
||||
const snap = activeRecording.snapshots[playbackIndex];
|
||||
|
||||
// スナップショット時刻 = 録画開始時刻 + elapsed
|
||||
const snapTime = dayjs(activeRecording.recordedAt).add(snap.t, "ms");
|
||||
const timeLabel = snapTime.format("HH:mm:ss");
|
||||
|
||||
// 録画の総時間をフォーマット
|
||||
const totalSec = Math.round(activeRecording.durationMs / 1000);
|
||||
const totalLabel =
|
||||
totalSec >= 60
|
||||
? `${Math.floor(totalSec / 60)}:${String(totalSec % 60).padStart(2, "0")}`
|
||||
: `${totalSec}s`;
|
||||
|
||||
const progress = total > 1 ? playbackIndex / (total - 1) : 0;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -282,7 +282,7 @@ export const DataSourceSettings = () => {
|
||||
setMockApiFeatureEnabled,
|
||||
recorderState,
|
||||
recordingSnapshotCount,
|
||||
savedRecording,
|
||||
recordingList,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
startPlayback,
|
||||
@@ -499,13 +499,11 @@ export const DataSourceSettings = () => {
|
||||
? `録画中… ${recordingSnapshotCount} スナップショット`
|
||||
: recorderState === 'playing'
|
||||
? '再生中'
|
||||
: savedRecording
|
||||
? `保存済み: ${savedRecording.snapshots.length} スナップショット / ${Math.round(savedRecording.durationMs / 1000)}秒`
|
||||
: '録画なし'}
|
||||
: `${recordingList.length} 件の録画`}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* ボタン行 */}
|
||||
{/* 録画開始 / 停止ボタン */}
|
||||
<View style={{ flexDirection: "row", gap: 8, marginTop: 10, flexWrap: "wrap" }}>
|
||||
{recorderState === 'idle' && (
|
||||
<TouchableOpacity
|
||||
@@ -529,17 +527,6 @@ export const DataSourceSettings = () => {
|
||||
<Text style={{ color: colors.textPrimary, fontWeight: 'bold', fontSize: 13 }}>■ 録画停止</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{recorderState === 'idle' && savedRecording && (
|
||||
<TouchableOpacity
|
||||
onPress={startPlayback}
|
||||
style={{
|
||||
backgroundColor: '#43a047', borderRadius: 8,
|
||||
paddingHorizontal: 14, paddingVertical: 8,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontWeight: 'bold', fontSize: 13 }}>▶ 再生</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{recorderState === 'playing' && (
|
||||
<TouchableOpacity
|
||||
onPress={stopPlayback}
|
||||
@@ -551,18 +538,65 @@ export const DataSourceSettings = () => {
|
||||
<Text style={{ color: colors.textPrimary, fontWeight: 'bold', fontSize: 13 }}>■ 再生停止</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{recorderState === 'idle' && savedRecording && (
|
||||
<TouchableOpacity
|
||||
onPress={deleteRecording}
|
||||
style={{
|
||||
borderColor: '#e53935', borderWidth: 1, borderRadius: 8,
|
||||
paddingHorizontal: 14, paddingVertical: 8,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#e53935', fontSize: 13 }}>削除</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 録画一覧 */}
|
||||
{recordingList.length > 0 && recorderState !== 'recording' && (
|
||||
<View style={{ marginTop: 10, gap: 6 }}>
|
||||
{recordingList.map((rec) => {
|
||||
const isPlaying = recorderState === 'playing';
|
||||
const durationSec = Math.round(rec.durationMs / 1000);
|
||||
const durationLabel = durationSec >= 60
|
||||
? `${Math.floor(durationSec / 60)}分${durationSec % 60}秒`
|
||||
: `${durationSec}秒`;
|
||||
const dateLabel = new Date(rec.recordedAt).toLocaleString('ja-JP', {
|
||||
month: 'numeric', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
return (
|
||||
<View
|
||||
key={rec.id}
|
||||
style={{
|
||||
flexDirection: 'row', alignItems: 'center',
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
borderRadius: 8, padding: 10, gap: 8,
|
||||
}}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ color: colors.textPrimary, fontSize: 13, fontWeight: 'bold' }}>
|
||||
{dateLabel}
|
||||
</Text>
|
||||
<Text style={{ color: colors.textSecondary, fontSize: 11 }}>
|
||||
{rec.snapshotCount} コマ / {durationLabel}
|
||||
</Text>
|
||||
</View>
|
||||
{!isPlaying && (
|
||||
<TouchableOpacity
|
||||
onPress={() => startPlayback(rec.id)}
|
||||
style={{
|
||||
backgroundColor: '#43a047', borderRadius: 6,
|
||||
paddingHorizontal: 10, paddingVertical: 6,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontWeight: 'bold', fontSize: 12 }}>▶</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{!isPlaying && (
|
||||
<TouchableOpacity
|
||||
onPress={() => deleteRecording(rec.id)}
|
||||
style={{
|
||||
borderColor: '#e53935', borderWidth: 1, borderRadius: 6,
|
||||
paddingHorizontal: 10, paddingVertical: 6,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#e53935', fontSize: 12 }}>削除</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View
|
||||
|
||||
@@ -115,7 +115,13 @@ export const STORAGE_KEYS = {
|
||||
/** モックAPI検証機能の有効化スイッチ(admin専用) */
|
||||
MOCK_API_FEATURE_ENABLED: 'mockApiFeatureEnabled',
|
||||
|
||||
/** 走行位置録画データ(admin専用モックAPI検証用) */
|
||||
/** 走行位置録画インデックス(admin専用 / 複数録画のメタ情報一覧) */
|
||||
MOCK_RECORDINGS_INDEX: 'mockRecordingsIndex',
|
||||
|
||||
/** 走行位置録画データプレフィックス(admin専用 / + id でキーを構成) */
|
||||
MOCK_RECORDING_DATA_PREFIX: 'mockRecordingData_',
|
||||
|
||||
/** 走行位置録画データ(旧フォーマット / マイグレーション用) */
|
||||
MOCK_RECORDING: 'mockRecording',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -8,24 +8,87 @@ export type TrainSnapshot = {
|
||||
trains: TrainEntry[];
|
||||
};
|
||||
|
||||
export type TrainRecording = {
|
||||
/** ISO 8601 timestamp */
|
||||
/** 録画のメタ情報(一覧表示用・軽量) */
|
||||
export type RecordingMeta = {
|
||||
id: string;
|
||||
recordedAt: string;
|
||||
durationMs: number;
|
||||
snapshotCount: number;
|
||||
};
|
||||
|
||||
/** フル録画データ(再生時のみロード) */
|
||||
export type TrainRecording = {
|
||||
id: string;
|
||||
recordedAt: string;
|
||||
/** Total duration in ms */
|
||||
durationMs: number;
|
||||
snapshots: TrainSnapshot[];
|
||||
};
|
||||
|
||||
export const saveRecording = (r: TrainRecording): Promise<void> =>
|
||||
AS.setItem(STORAGE_KEYS.MOCK_RECORDING, JSON.stringify(r));
|
||||
const parse = <T>(raw: string | null | boolean): T | null => {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(typeof raw === 'string' ? raw : JSON.stringify(raw)) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const loadRecording = (): Promise<TrainRecording | null> =>
|
||||
AS.getItem(STORAGE_KEYS.MOCK_RECORDING)
|
||||
.then((raw) => {
|
||||
if (!raw) return null;
|
||||
return JSON.parse(typeof raw === 'string' ? raw : JSON.stringify(raw)) as TrainRecording;
|
||||
})
|
||||
.catch(() => null);
|
||||
/** 録画IDを生成(recordedAt ISO文字列からファイル名に使えるIDへ) */
|
||||
export const generateRecordingId = (recordedAt: string) =>
|
||||
recordedAt.replace(/[:.]/g, '-');
|
||||
|
||||
/** 録画インデックス(メタ一覧)を読み込む */
|
||||
export const loadRecordingList = async (): Promise<RecordingMeta[]> => {
|
||||
const raw = await AS.getItem(STORAGE_KEYS.MOCK_RECORDINGS_INDEX).catch(() => null);
|
||||
return parse<RecordingMeta[]>(raw) ?? [];
|
||||
};
|
||||
|
||||
/** フル録画データをIDで読み込む */
|
||||
export const loadRecordingById = async (id: string): Promise<TrainRecording | null> => {
|
||||
const raw = await AS.getItem((STORAGE_KEYS.MOCK_RECORDING_DATA_PREFIX + id) as any).catch(() => null);
|
||||
return parse<TrainRecording>(raw);
|
||||
};
|
||||
|
||||
/** 録画を保存してインデックスに追加する */
|
||||
export const saveRecording = async (recording: TrainRecording): Promise<void> => {
|
||||
await AS.setItem(
|
||||
(STORAGE_KEYS.MOCK_RECORDING_DATA_PREFIX + recording.id) as any,
|
||||
JSON.stringify(recording),
|
||||
);
|
||||
const list = await loadRecordingList();
|
||||
const meta: RecordingMeta = {
|
||||
id: recording.id,
|
||||
recordedAt: recording.recordedAt,
|
||||
durationMs: recording.durationMs,
|
||||
snapshotCount: recording.snapshots.length,
|
||||
};
|
||||
// 先頭に追加(新しい順)、同IDは重複排除
|
||||
const newList = [meta, ...list.filter((m) => m.id !== recording.id)];
|
||||
await AS.setItem(STORAGE_KEYS.MOCK_RECORDINGS_INDEX, JSON.stringify(newList));
|
||||
};
|
||||
|
||||
/** 録画をIDで削除する */
|
||||
export const deleteRecordingById = async (id: string): Promise<void> => {
|
||||
await AS.removeItem((STORAGE_KEYS.MOCK_RECORDING_DATA_PREFIX + id) as any).catch(() => {});
|
||||
const list = await loadRecordingList();
|
||||
await AS.setItem(
|
||||
STORAGE_KEYS.MOCK_RECORDINGS_INDEX,
|
||||
JSON.stringify(list.filter((m) => m.id !== id)),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 旧フォーマット(MOCK_RECORDING)からマイグレーション。
|
||||
* 新インデックスが空の場合のみ実行する。
|
||||
*/
|
||||
export const migrateOldRecording = async (): Promise<void> => {
|
||||
const list = await loadRecordingList();
|
||||
if (list.length > 0) return; // 既に移行済み
|
||||
const raw = await AS.getItem(STORAGE_KEYS.MOCK_RECORDING).catch(() => null);
|
||||
const old = parse<Omit<TrainRecording, 'id'>>(raw);
|
||||
if (!old) return;
|
||||
const id = generateRecordingId(old.recordedAt);
|
||||
await saveRecording({ ...old, id });
|
||||
await AS.removeItem(STORAGE_KEYS.MOCK_RECORDING).catch(() => {});
|
||||
};
|
||||
|
||||
export const deleteRecording = (): Promise<void> =>
|
||||
AS.removeItem(STORAGE_KEYS.MOCK_RECORDING);
|
||||
|
||||
@@ -16,9 +16,13 @@ import { MockApiConfig, TrainEntry } from "../lib/mockApi/webviewXhrInterceptor"
|
||||
import { MOCK_TRAIN_POSITIONS } from "../lib/mockApi";
|
||||
import {
|
||||
TrainRecording,
|
||||
RecordingMeta,
|
||||
saveRecording,
|
||||
loadRecording,
|
||||
deleteRecording as deleteRecordingStorage,
|
||||
loadRecordingList,
|
||||
loadRecordingById,
|
||||
deleteRecordingById,
|
||||
migrateOldRecording,
|
||||
generateRecordingId,
|
||||
} from "../lib/mockApi/trainRecorder";
|
||||
|
||||
import { useNotification } from "../stateBox/useNotifications";
|
||||
@@ -71,21 +75,24 @@ const initialState = {
|
||||
// --- 録画・再生 ---
|
||||
recorderState: 'idle' as 'idle' | 'recording' | 'playing',
|
||||
recordingSnapshotCount: 0,
|
||||
savedRecording: null as TrainRecording | null,
|
||||
/** 録画一覧(メタ情報) */
|
||||
recordingList: [] as RecordingMeta[],
|
||||
/** 再生中のフル録画データ */
|
||||
activeRecording: null as TrainRecording | null,
|
||||
/** 現在再生中のスナップショットインデックス */
|
||||
playbackIndex: 0,
|
||||
/** 再生一時停止中か */
|
||||
playbackPaused: false,
|
||||
startRecording: () => {},
|
||||
stopRecording: (): Promise<void> => Promise.resolve(),
|
||||
startPlayback: () => {},
|
||||
startPlayback: (_id: string): Promise<void> => Promise.resolve(),
|
||||
stopPlayback: () => {},
|
||||
pausePlayback: () => {},
|
||||
resumePlayback: () => {},
|
||||
/** 指定インデックスに直接ジャンプ(シーク) */
|
||||
seekToSnapshot: (_index: number) => {},
|
||||
addTrainSnapshot: (_trains: TrainEntry[]) => {},
|
||||
deleteRecording: (): Promise<void> => Promise.resolve(),
|
||||
deleteRecording: (_id: string): Promise<void> => Promise.resolve(),
|
||||
};
|
||||
|
||||
const TrainMenuContext = createContext(initialState);
|
||||
@@ -171,30 +178,31 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
type RecorderState = 'idle' | 'recording' | 'playing';
|
||||
const [recorderState, setRecorderState] = useState<RecorderState>('idle');
|
||||
const [recordingSnapshotCount, setRecordingSnapshotCount] = useState(0);
|
||||
const [savedRecording, setSavedRecording] = useState<TrainRecording | null>(null);
|
||||
const [recordingList, setRecordingList] = useState<RecordingMeta[]>([]);
|
||||
const [activeRecording, setActiveRecording] = useState<TrainRecording | null>(null);
|
||||
const [playbackIndex, setPlaybackIndex] = useState(0);
|
||||
const [playbackPaused, setPlaybackPaused] = useState(false);
|
||||
const recordingStartTimeRef = useRef<number>(0);
|
||||
const recordingSnapshotsRef = useRef<Array<{ t: number; trains: TrainEntry[] }>>([]);
|
||||
|
||||
// 起動時に保存済み録画を読み込む
|
||||
// 起動時: 旧フォーマット移行 → 一覧読み込み
|
||||
useEffect(() => {
|
||||
loadRecording().then((r) => { if (r) setSavedRecording(r); });
|
||||
migrateOldRecording().then(() => loadRecordingList()).then(setRecordingList).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 再生ループ: playbackIndex / recorderState / playbackPaused が変わるたびに次へ進める
|
||||
useEffect(() => {
|
||||
if (recorderState !== 'playing' || !savedRecording || savedRecording.snapshots.length === 0) return;
|
||||
if (recorderState !== 'playing' || !activeRecording || activeRecording.snapshots.length === 0) return;
|
||||
if (playbackPaused) return; // 一時停止中はタイマーを張らない
|
||||
const snap = savedRecording.snapshots[playbackIndex];
|
||||
const snap = activeRecording.snapshots[playbackIndex];
|
||||
setMockTrainPositions(snap.trains);
|
||||
const nextIndex = (playbackIndex + 1) % savedRecording.snapshots.length;
|
||||
const nextIndex = (playbackIndex + 1) % activeRecording.snapshots.length;
|
||||
const delay = nextIndex === 0
|
||||
? 15000
|
||||
: Math.max(savedRecording.snapshots[nextIndex].t - snap.t, 3000);
|
||||
: Math.max(activeRecording.snapshots[nextIndex].t - snap.t, 3000);
|
||||
const timer = setTimeout(() => setPlaybackIndex(nextIndex), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [recorderState, playbackIndex, savedRecording, playbackPaused]);
|
||||
}, [recorderState, playbackIndex, activeRecording, playbackPaused]);
|
||||
|
||||
const startRecording = () => {
|
||||
// 録画中はライブデータを取得するためモックをOFF
|
||||
@@ -209,21 +217,26 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
const stopRecording = async () => {
|
||||
const snaps = recordingSnapshotsRef.current;
|
||||
if (snaps.length > 0) {
|
||||
const recordedAt = new Date(recordingStartTimeRef.current).toISOString();
|
||||
const recording: TrainRecording = {
|
||||
recordedAt: new Date(recordingStartTimeRef.current).toISOString(),
|
||||
id: generateRecordingId(recordedAt),
|
||||
recordedAt,
|
||||
durationMs: snaps[snaps.length - 1].t,
|
||||
snapshots: snaps,
|
||||
};
|
||||
setSavedRecording(recording);
|
||||
await saveRecording(recording);
|
||||
const list = await loadRecordingList();
|
||||
setRecordingList(list);
|
||||
}
|
||||
setRecorderState('idle');
|
||||
};
|
||||
|
||||
const startPlayback = () => {
|
||||
if (!savedRecording || savedRecording.snapshots.length === 0) return;
|
||||
const startPlayback = async (id: string) => {
|
||||
const recording = await loadRecordingById(id);
|
||||
if (!recording || recording.snapshots.length === 0) return;
|
||||
setMockApiFeatureEnabledState(true);
|
||||
AS.setItem(STORAGE_KEYS.MOCK_API_FEATURE_ENABLED, 'true');
|
||||
setActiveRecording(recording);
|
||||
setPlaybackIndex(0);
|
||||
setPlaybackPaused(false);
|
||||
setRecorderState('playing');
|
||||
@@ -232,18 +245,18 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
const stopPlayback = () => {
|
||||
setRecorderState('idle');
|
||||
setPlaybackPaused(false);
|
||||
setActiveRecording(null);
|
||||
};
|
||||
|
||||
const pausePlayback = () => setPlaybackPaused(true);
|
||||
const resumePlayback = () => setPlaybackPaused(false);
|
||||
|
||||
const seekToSnapshot = (index: number) => {
|
||||
if (!savedRecording) return;
|
||||
const i = Math.max(0, Math.min(index, savedRecording.snapshots.length - 1));
|
||||
if (!activeRecording) return;
|
||||
const i = Math.max(0, Math.min(index, activeRecording.snapshots.length - 1));
|
||||
setPlaybackIndex(i);
|
||||
// シーク時は一時停止して、その画面を表示する
|
||||
setPlaybackPaused(true);
|
||||
setMockTrainPositions(savedRecording.snapshots[i].trains);
|
||||
setMockTrainPositions(activeRecording.snapshots[i].trains);
|
||||
};
|
||||
|
||||
// useCurrentTrain から呼ばれる: ライブfetch成功時にスナップショットを追記
|
||||
@@ -256,9 +269,10 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
setRecordingSnapshotCount(recordingSnapshotsRef.current.length);
|
||||
};
|
||||
|
||||
const deleteRecording = async () => {
|
||||
setSavedRecording(null);
|
||||
await deleteRecordingStorage();
|
||||
const deleteRecording = async (id: string) => {
|
||||
await deleteRecordingById(id);
|
||||
const list = await loadRecordingList();
|
||||
setRecordingList(list);
|
||||
};
|
||||
|
||||
const mockApiConfig: MockApiConfig | null =
|
||||
@@ -372,7 +386,8 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
setMockApiFeatureEnabled,
|
||||
recorderState,
|
||||
recordingSnapshotCount,
|
||||
savedRecording,
|
||||
recordingList,
|
||||
activeRecording,
|
||||
playbackIndex,
|
||||
playbackPaused,
|
||||
startRecording,
|
||||
|
||||
Reference in New Issue
Block a user