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:
harukin-expo-dev-env
2026-05-02 00:52:30 +00:00
parent 8144e8a48a
commit 4f4d3cad0a
5 changed files with 218 additions and 95 deletions

View File

@@ -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={{

View File

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

View File

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

View File

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

View File

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