Files
jrshikoku/stateBox/useTrainMenu.tsx

634 lines
22 KiB
TypeScript

import React, {
createContext,
useContext,
useState,
useEffect,
useRef,
useCallback,
FC,
} from "react";
import { Platform } from "react-native";
import * as DocumentPicker from "expo-document-picker";
import { File, Paths } from "expo-file-system";
import Share from "react-native-share";
import { ASCore } from "../storageControl";
import { AS } from "../storageControl";
import { getStationList2 } from "../lib/getStationList";
import { injectJavascriptData, generateBeforeContentLoadedScript } from "../lib/webViewInjectjavascript";
import { MockApiConfig, TrainEntry } from "../lib/mockApi/webviewXhrInterceptor";
import { MOCK_TRAIN_POSITIONS } from "../lib/mockApi";
import {
PositionMaster,
PositionLookup,
fetchPositionMasters,
fetchMockTrainPositions,
buildPosLookup,
lookupPos,
} from "../lib/mockApi/positionMasters";
import {
TrainRecording,
RecordingMeta,
RecordingImportResult,
saveRecording,
loadRecordingList,
loadRecordingById,
deleteRecordingById,
migrateOldRecording,
generateRecordingId,
buildRecordingExportText as buildRecordingExportTextCore,
buildAllRecordingsExportText as buildAllRecordingsExportTextCore,
importRecordingsFromText as importRecordingsFromTextCore,
} from "../lib/mockApi/trainRecorder";
import { useNotification } from "../stateBox/useNotifications";
import { useThemeColors } from "@/lib/theme";
import { STORAGE_KEYS } from "@/constants/storage";
import {
normalizeIconDisplayMode,
type IconDisplayMode,
} from "@/lib/iconDisplayMode";
import {
getBackendApiBaseUrl,
getDiagramTodayUrl,
BACKEND_API_BASE_URLS,
} from "@/lib/jrDataSystemEnvironment";
const initialState = {
selectedLine: undefined,
setSelectedLine: (e) => {},
mapsStationData: undefined,
setMapsStationData: (e) => {},
iconSetting: undefined,
setIconSetting: (e) => {},
mapSwitch: undefined,
setMapSwitch: (e) => {},
stationMenu: undefined,
setStationMenu: (e) => {},
uiSetting: undefined,
setUiSetting: (e) => {},
LoadError: false,
setLoadError: (e) => {},
trainInfo: {
trainNum: undefined,
limited: undefined,
trainData: undefined,
},
setTrainInfo: (e) => {},
trainMenu: "true",
setTrainMenu: (e) => {},
updatePermission: false,
setUpdatePermission: (e) => {},
/** バックエンドが返したユーザーロール */
userPermissionRole: "",
/** crew/administrator向け音声機能の表示・利用権限 */
restrictedSoundPermission: false,
/** 各情報ソースの利用権限 */
dataSourcePermission: { unyohub: false, elesite: false } as {
unyohub: boolean;
elesite: boolean;
},
injectJavascript: "",
/** injectedJavaScriptBeforeContentLoaded用(XHRインターセプター) */
injectJavascriptBeforeContentLoaded: "",
/** WebView内の公式サイトAPIに流し込むモック列車位置データ */
mockTrainPositions: null as TrainEntry[] | null,
setMockTrainPositions: (e: TrainEntry[] | null) => {},
/** モックAPI検証機能が設定で有効化されているか(admin専用) */
mockApiFeatureEnabled: false,
setMockApiFeatureEnabled: (e: boolean) => {},
/** 位置マスターデータ(mock API server から取得) */
positionMasters: [] as PositionMaster[],
/** PosNum + Line → Pos テキスト変換 */
lookupPosText: (_posNum: number, _line: string): string | undefined => undefined,
// --- 録画・再生 ---
recorderState: 'idle' as 'idle' | 'recording' | 'playing',
recordingSnapshotCount: 0,
/** 録画一覧(メタ情報) */
recordingList: [] as RecordingMeta[],
/** 再生中のフル録画データ */
activeRecording: null as TrainRecording | null,
/** 現在再生中のスナップショットインデックス */
playbackIndex: 0,
/** 再生中スナップショットに対応する基準時刻(通常時は null) */
playbackCurrentTimeIso: null as string | null,
/** 再生一時停止中か */
playbackPaused: false,
startRecording: () => {},
stopRecording: (): Promise<void> => Promise.resolve(),
startPlayback: (_id: string): Promise<void> => Promise.resolve(),
stopPlayback: () => {},
pausePlayback: () => {},
resumePlayback: () => {},
/** 指定インデックスに直接ジャンプ(シーク) */
seekToSnapshot: (_index: number) => {},
addTrainSnapshot: (_trains: TrainEntry[]) => {},
deleteRecording: (_id: string): Promise<void> => Promise.resolve(),
exportRecordingFile: (_id: string): Promise<void> => Promise.resolve(),
exportAllRecordingsFile: (): Promise<void> => Promise.resolve(),
importRecordingFile: (): Promise<RecordingImportResult | null> => Promise.resolve(null),
importRecordingsFromText: (_content: string): Promise<RecordingImportResult> =>
Promise.resolve({ importedCount: 0, overwrittenCount: 0 }),
};
const TrainMenuContext = createContext(initialState);
export const useTrainMenu = () => {
return useContext(TrainMenuContext);
};
type props = { children: React.ReactNode };
export const TrainMenuProvider: FC<props> = ({ children }) => {
const { expoPushToken } = useNotification();
const [selectedLine, setSelectedLine] = useState(undefined);
const [mapsStationData, setMapsStationData] = useState(undefined);
useEffect(() => {
getStationList2().then(setMapsStationData);
}, []);
type boolType = "true" | "false" | undefined;
//画面表示関連
const [iconSetting, setIconSetting] = useState<IconDisplayMode | undefined>(
undefined,
);
const [mapSwitch, setMapSwitch] = useState<boolType>(undefined);
const [stationMenu, setStationMenu] = useState<boolType>(undefined);
const [LoadError, setLoadError] = useState(false);
// バックエンドAPIベースURL(環境設定から読み込み)
const [backendApiBaseUrl, setBackendApiBaseUrl] = useState<string>(
BACKEND_API_BASE_URLS.production,
);
const [diagramTodayUrl, setDiagramTodayUrl] = useState<string>(
"https://jr-shikoku-api-data-storage.haruk.in/tmp/diagram-today.json",
);
//更新権限所有確認・情報ソース別利用権限(将来ロールが増えたらここに足す)
const [updatePermission, setUpdatePermission] = useState(false);
const [userPermissionRole, setUserPermissionRole] = useState("");
const [restrictedSoundPermission, setRestrictedSoundPermission] =
useState(false);
const [dataSourcePermission, setDataSourcePermission] = useState<{
unyohub: boolean;
elesite: boolean;
}>({ unyohub: false, elesite: false });
useEffect(() => {
if (!expoPushToken) {
setUserPermissionRole("");
setUpdatePermission(false);
setRestrictedSoundPermission(false);
setDataSourcePermission({ unyohub: false, elesite: false });
return;
}
setUserPermissionRole("");
setUpdatePermission(false);
setRestrictedSoundPermission(false);
setDataSourcePermission({ unyohub: false, elesite: false });
const permissionController = new AbortController();
fetch(
`${backendApiBaseUrl}/check-permission?user_id=${expoPushToken}`,
{ signal: permissionController.signal },
)
.then((res) => res.json())
.then((res) => {
if (permissionController.signal.aborted) return;
const role: string = res.permission ?? "";
const normalizedRole = role.trim().toLowerCase();
const isAdministrator = normalizedRole === "administrator";
setUserPermissionRole(normalizedRole);
setUpdatePermission(isAdministrator);
setRestrictedSoundPermission(
normalizedRole === "crew" || isAdministrator,
);
setDataSourcePermission({
unyohub: isAdministrator || role === "unyoHubEditor",
elesite: isAdministrator || role === "eleSiteEditor",
});
})
.catch(() => {
if (permissionController.signal.aborted) return;
setUserPermissionRole("");
setUpdatePermission(false);
setRestrictedSoundPermission(false);
setDataSourcePermission({ unyohub: false, elesite: false });
});
return () => permissionController.abort();
}, [expoPushToken, backendApiBaseUrl]);
//列車情報表示関連
const [trainInfo, setTrainInfo] = useState({
trainNum: undefined,
limited: undefined,
trainData: undefined,
});
//駅情報画面用
const [trainMenu, setTrainMenu] = useState("true");
//GUIデザインベース
const [uiSetting, setUiSetting] = useState("tokyo");
// 鉄道運用Hub使用設定
const [useUnyohubSetting, setUseUnyohubSetting] = useState("false");
// えれサイト使用設定
const [useEleSiteSetting, setUseEleSiteSetting] = useState("false");
// モックAPI設定
const [mockTrainPositions, setMockTrainPositions] = useState<TrainEntry[] | null>(null);
// 位置マスター(mock API server から取得・キャッシュ)
const [positionMasters, setPositionMasters] = useState<PositionMaster[]>([]);
const [posLookup, setPosLookup] = useState<PositionLookup>(new Map());
// admin専用: モックAPI検証機能の有効化(永続化)
const [mockApiFeatureEnabled, setMockApiFeatureEnabledState] = useState(false);
const setMockApiFeatureEnabled = (value: boolean) => {
setMockApiFeatureEnabledState(value);
AS.setItem(STORAGE_KEYS.MOCK_API_FEATURE_ENABLED, value.toString());
// 機能をオフにしたらデータをリセット
if (!value) setMockTrainPositions(MOCK_TRAIN_POSITIONS);
// 機能をオンにしたら位置マスターを取得(未取得の場合)
if (value && positionMasters.length === 0) {
fetchPositionMasters()
.then((masters) => {
setPositionMasters(masters);
setPosLookup(buildPosLookup(masters));
})
.catch(() => {});
}
};
// --- 録画・再生 ---
type RecorderState = 'idle' | 'recording' | 'playing';
const [recorderState, setRecorderState] = useState<RecorderState>('idle');
const [recordingSnapshotCount, setRecordingSnapshotCount] = useState(0);
const [recordingList, setRecordingList] = useState<RecordingMeta[]>([]);
const [activeRecording, setActiveRecording] = useState<TrainRecording | null>(null);
const [playbackIndex, setPlaybackIndex] = useState(0);
const playbackCurrentTimeIso =
recorderState === 'playing' && activeRecording && activeRecording.snapshots.length > 0
? new Date(
new Date(activeRecording.recordedAt).getTime() +
(activeRecording.snapshots[playbackIndex]?.t ?? 0)
).toISOString()
: null;
const [playbackPaused, setPlaybackPaused] = useState(false);
const recordingStartTimeRef = useRef<number>(0);
const recordingSnapshotsRef = useRef<Array<{ t: number; trains: TrainEntry[] }>>([]);
const refreshRecordingList = async () => {
const list = await loadRecordingList();
setRecordingList(list);
};
// 起動時: 旧フォーマット移行 → 一覧読み込み
useEffect(() => {
migrateOldRecording().then(refreshRecordingList).catch(() => {});
}, []);
// 再生ループ: playbackIndex / recorderState / playbackPaused が変わるたびに次へ進める
useEffect(() => {
if (recorderState !== 'playing' || !activeRecording || activeRecording.snapshots.length === 0) return;
if (playbackPaused) return; // 一時停止中はタイマーを張らない
const snap = activeRecording.snapshots[playbackIndex];
setMockTrainPositions(snap.trains);
const nextIndex = (playbackIndex + 1) % activeRecording.snapshots.length;
const delay = nextIndex === 0
? 15000
: Math.max(activeRecording.snapshots[nextIndex].t - snap.t, 3000);
const timer = setTimeout(() => setPlaybackIndex(nextIndex), delay);
return () => clearTimeout(timer);
}, [recorderState, playbackIndex, activeRecording, playbackPaused]);
const startRecording = () => {
// 録画中はライブデータを取得するためモックをOFF
setMockApiFeatureEnabledState(false);
AS.setItem(STORAGE_KEYS.MOCK_API_FEATURE_ENABLED, 'false');
recordingStartTimeRef.current = Date.now();
recordingSnapshotsRef.current = [];
setRecordingSnapshotCount(0);
setRecorderState('recording');
};
const stopRecording = async () => {
const snaps = recordingSnapshotsRef.current;
if (snaps.length > 0) {
const recordedAt = new Date(recordingStartTimeRef.current).toISOString();
const recording: TrainRecording = {
id: generateRecordingId(recordedAt),
recordedAt,
durationMs: snaps[snaps.length - 1].t,
snapshots: snaps,
};
await saveRecording(recording);
await refreshRecordingList();
}
setRecorderState('idle');
};
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');
};
const stopPlayback = () => {
setRecorderState('idle');
setPlaybackPaused(false);
setActiveRecording(null);
};
const pausePlayback = () => setPlaybackPaused(true);
const resumePlayback = () => setPlaybackPaused(false);
const seekToSnapshot = (index: number) => {
if (!activeRecording) return;
const i = Math.max(0, Math.min(index, activeRecording.snapshots.length - 1));
setPlaybackIndex(i);
setPlaybackPaused(true);
setMockTrainPositions(activeRecording.snapshots[i].trains);
};
// useCurrentTrain から呼ばれる: ライブfetch成功時にスナップショットを追記
const addTrainSnapshot = (trains: TrainEntry[]) => {
if (recorderState !== 'recording') return;
recordingSnapshotsRef.current.push({
t: Date.now() - recordingStartTimeRef.current,
trains,
});
setRecordingSnapshotCount(recordingSnapshotsRef.current.length);
};
const deleteRecording = async (id: string) => {
await deleteRecordingById(id);
await refreshRecordingList();
};
const downloadTextFileOnWeb = (fileName: string, content: string) => {
const web = globalThis as any;
if (!web.document || !web.URL || !web.Blob) {
throw new Error('この環境ではファイルのダウンロードに対応していません。');
}
const blob = new web.Blob([content], { type: 'application/json' });
const url = web.URL.createObjectURL(blob);
const anchor = web.document.createElement('a');
anchor.href = url;
anchor.download = fileName;
web.document.body.appendChild(anchor);
anchor.click();
anchor.remove();
web.setTimeout(() => web.URL.revokeObjectURL(url), 0);
};
const shareJsonFile = async (fileName: string, content: string) => {
if (Platform.OS === 'web') {
downloadTextFileOnWeb(fileName, content);
return;
}
const file = new File(Paths.cache, fileName);
if (file.exists) {
file.delete();
}
file.create({ overwrite: true });
file.write(content);
await Share.open({
title: fileName,
subject: fileName,
url: file.uri,
type: 'application/json',
filename: fileName.replace(/\.json$/i, ''),
failOnCancel: false,
saveToFiles: true,
useInternalStorage: true,
});
};
const exportRecordingFile = async (id: string) => {
const content = await buildRecordingExportTextCore(id);
await shareJsonFile(`jrshikoku-recording-${id}.json`, content);
};
const exportAllRecordingsFile = async () => {
const content = await buildAllRecordingsExportTextCore();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
await shareJsonFile(`jrshikoku-recordings-${timestamp}.json`, content);
};
const readPickedRecordingText = async (asset: DocumentPicker.DocumentPickerAsset) => {
const webFile = (asset as any).file;
if (Platform.OS === 'web' && webFile && typeof webFile.text === 'function') {
return webFile.text();
}
const file = new File(asset.uri);
return file.text();
};
const importRecordingFile = async (): Promise<RecordingImportResult | null> => {
const result = await DocumentPicker.getDocumentAsync({
type: ['application/json', 'text/json', 'text/plain', '*/*'],
copyToCacheDirectory: true,
multiple: false,
base64: false,
});
if (result.canceled || !result.assets?.[0]) return null;
const content = await readPickedRecordingText(result.assets[0]);
const importResult = await importRecordingsFromTextCore(content);
await refreshRecordingList();
return importResult;
};
const importRecordingsFromText = async (content: string): Promise<RecordingImportResult> => {
const importResult = await importRecordingsFromTextCore(content);
await refreshRecordingList();
return importResult;
};
/** PosNum + Line → Pos テキスト(位置マスターから) */
const lookupPosText = (posNum: number, line: string): string | undefined =>
lookupPos(posNum, line, posLookup);
const mockApiConfig: MockApiConfig | null =
mockApiFeatureEnabled && mockTrainPositions
? { trainPositions: mockTrainPositions, positionMasters }
: null;
//地図表示テキスト
const injectJavascript = injectJavascriptData({
mapSwitch,
iconSetting,
stationMenu,
trainMenu,
uiSetting,
useUnyohub: useUnyohubSetting,
useElesite: useEleSiteSetting,
isDark: useThemeColors().isDark,
backendApiBaseUrl,
diagramTodayUrl,
mockApiConfig,
});
// XHRインターセプターはページスクリプトより前に実行が必要
const injectJavascriptBeforeContentLoaded = generateBeforeContentLoadedScript(mockApiConfig);
useEffect(() => {
//列車アイコンスイッチ
ASCore({
k: STORAGE_KEYS.ICON_SWITCH,
s: (value) => setIconSetting(normalizeIconDisplayMode(value)),
d: "original",
u: true,
});
//地図スイッチ
ASCore({ k: STORAGE_KEYS.MAP_SWITCH, s: setMapSwitch, d: "true", u: true });
//駅メニュースイッチ
ASCore({
k: STORAGE_KEYS.STATION_SWITCH,
s: setStationMenu,
d: "true",
u: true,
});
//列車メニュースイッチ
ASCore({
k: STORAGE_KEYS.TRAIN_SWITCH,
s: setTrainMenu,
d: "true",
u: true,
});
//GUIデザインベーススイッチ
ASCore({
k: STORAGE_KEYS.UI_SETTING,
s: setUiSetting,
d: "tokyo",
u: true,
});
//鉄道運用Hubスイッチ
ASCore({
k: STORAGE_KEYS.USE_UNYOHUB,
s: setUseUnyohubSetting,
d: "false",
u: true,
});
//えれサイトスイッチ
ASCore({
k: STORAGE_KEYS.USE_ELESITE,
s: setUseEleSiteSetting,
d: "false",
u: true,
});
//モックAPI検証機能スイッチ(admin専用・再起動不要)
AS.getItem(STORAGE_KEYS.MOCK_API_FEATURE_ENABLED).then((value) => {
const enabled = value === "true" || value === true;
setMockApiFeatureEnabledState(enabled);
// 起動時に既に有効なら位置マスターを取得
if (enabled) {
fetchPositionMasters()
.then((masters) => {
setPositionMasters(masters);
setPosLookup(buildPosLookup(masters));
})
.catch(() => {});
}
}).catch(() => {});
// バックエンドAPIベースURL(環境設定から読み込み)
AS.getItem(STORAGE_KEYS.JR_DATA_SYSTEM_ENV).then((value) => {
setBackendApiBaseUrl(getBackendApiBaseUrl(value));
setDiagramTodayUrl(getDiagramTodayUrl(value));
}).catch(() => {});
// 静的サンプルデータで初期化(モックAPIが応答するまでのフォールバック)
setMockTrainPositions(MOCK_TRAIN_POSITIONS);
}, []);
// モックAPIポーリング: モックON かつ 録画/再生中でない場合に15秒ごとに更新
const fetchAndSetMockPositions = useCallback(() => {
if (!mockApiFeatureEnabled || recorderState !== 'idle') return;
fetchMockTrainPositions()
.then((data) => {
const entries = data.filter((x: any) => "TrainNum" in x) as TrainEntry[];
setMockTrainPositions(entries);
})
.catch(() => {});
}, [mockApiFeatureEnabled, recorderState]);
useEffect(() => {
if (!mockApiFeatureEnabled || recorderState !== 'idle') return;
// 即時取得
fetchAndSetMockPositions();
// 15秒ごとにポーリング
const timer = setInterval(fetchAndSetMockPositions, 15000);
return () => clearInterval(timer);
}, [mockApiFeatureEnabled, recorderState]);
return (
<TrainMenuContext.Provider
value={{
selectedLine,
setSelectedLine,
mapsStationData,
setMapsStationData,
iconSetting,
setIconSetting,
mapSwitch,
setMapSwitch,
stationMenu,
setStationMenu,
uiSetting,
setUiSetting,
LoadError,
setLoadError,
trainInfo,
setTrainInfo,
trainMenu,
setTrainMenu,
updatePermission,
setUpdatePermission,
userPermissionRole,
restrictedSoundPermission,
dataSourcePermission,
injectJavascript,
injectJavascriptBeforeContentLoaded,
mockTrainPositions,
setMockTrainPositions,
mockApiFeatureEnabled,
setMockApiFeatureEnabled,
positionMasters,
lookupPosText,
recorderState,
recordingSnapshotCount,
recordingList,
activeRecording,
playbackIndex,
playbackCurrentTimeIso,
playbackPaused,
startRecording,
stopRecording,
startPlayback,
stopPlayback,
pausePlayback,
resumePlayback,
seekToSnapshot,
addTrainSnapshot,
deleteRecording,
exportRecordingFile,
exportAllRecordingsFile,
importRecordingFile,
importRecordingsFromText,
}}
>
{children}
</TrainMenuContext.Provider>
);
};