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>
95 lines
3.2 KiB
TypeScript
95 lines
3.2 KiB
TypeScript
import { AS } from '../../storageControl';
|
||
import { STORAGE_KEYS } from '../../constants/storage';
|
||
import { TrainEntry } from './webviewXhrInterceptor';
|
||
|
||
export type TrainSnapshot = {
|
||
/** ms elapsed from recording start */
|
||
t: number;
|
||
trains: TrainEntry[];
|
||
};
|
||
|
||
/** 録画のメタ情報(一覧表示用・軽量) */
|
||
export type RecordingMeta = {
|
||
id: string;
|
||
recordedAt: string;
|
||
durationMs: number;
|
||
snapshotCount: number;
|
||
};
|
||
|
||
/** フル録画データ(再生時のみロード) */
|
||
export type TrainRecording = {
|
||
id: string;
|
||
recordedAt: string;
|
||
durationMs: number;
|
||
snapshots: TrainSnapshot[];
|
||
};
|
||
|
||
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;
|
||
}
|
||
};
|
||
|
||
/** 録画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(() => {});
|
||
};
|
||
|