Compare commits

..
265 Commits
Author SHA1 Message Date
harukin-expo-dev-env be69c365ef Merge commit 'ff8313ee42834d115fe0f5f4865a166766cfc094' into develop 2026-07-30 08:12:35 +00:00
harukin-expo-dev-env ff8313ee42 feat: 音声案内テキストの全角スペースを適切な読点に置換し、発車時刻表の表示を改善 2026-07-30 08:12:22 +00:00
harukin-expo-dev-env 22ce82a799 feat: りっかちゃん音声案内基盤を拡張 2026-07-29 05:31:35 +00:00
harukin-expo-dev-env cae1030bea docs: Voicepeak公開APIの要件を追加 2026-07-29 05:30:41 +00:00
harukin-expo-dev-env 3511d27726 docs: バックグラウンドりっかちゃんの構成案を追加 2026-07-29 05:30:16 +00:00
harukin-expo-dev-env d16bae0d45 feat: 運行情報表示を更新 2026-07-29 05:29:12 +00:00
harukin-expo-dev-env 37c3a80483 Merge commit '5df00c8b1766969c3c71154733dc4fa098fd8468' into experimental/station-announce-tools 2026-07-15 08:04:41 +00:00
harukin-expo-dev-env 5df00c8b17 Merge commit '0d5c66c15e941b5769957b7e0d6730db75fbb7fa' into develop 2026-07-15 08:04:16 +00:00
harukin-expo-dev-env 0d5c66c15e fix: 通過駅のフィルタリングロジックを改善し、列車データの取得処理を最適化 2026-07-15 08:04:05 +00:00
harukin-expo-dev-env a89c896012 fix: 列車情報の時間フィルタリングロジックを改善し、再生中の基準時刻を追加 2026-07-14 10:49:31 +00:00
harukin-expo-dev-env dfec6d1c89 fix: 列車情報の表示ロジックを改善し、条件付きHTML生成を追加 2026-07-14 09:11:23 +00:00
harukin-expo-dev-env b97531d5b4 feat: 列車タイプのラッピング処理を追加し、表示ロジックを改善 2026-07-14 05:57:05 +00:00
harukin-expo-dev-env bb6873eb10 feat: JR-WEST-PLUSフォントの調整と表示領域の正規化を実施 2026-07-14 04:26:38 +00:00
harukin-expo-dev-env 2e6586260c feat: JR-WEST-PLUSフォントを追加し、フォント設定を最適化 2026-07-14 02:51:41 +00:00
harukin-expo-dev-env 12b01edb3e fix: 修正された列車データの取得と表示ロジックを改善し、未登録列車の処理を追加 2026-07-14 00:46:35 +00:00
harukin-expo-dev-env ca045ca3a3 feat: Implement X投稿向け運行情報画像セット generation in ndView.tsx
- Added functionality to generate a set of images for posting to X (formerly Twitter) with multiple images.
- Introduced a new button for creating X投稿向け画像, which generates up to 4 PNG images.
- The first image serves as a cover with a route map and summary, while subsequent images contain detailed operation information.
- Ensured images maintain a fixed size of 1080 x 1440 px and follow specific naming conventions.
- Implemented error handling for image generation failures and ensured compatibility with existing sharing features.
- Updated UI elements and styles for the new functionality, including button states and layout adjustments.
- Added necessary TypeScript type checks and validation for the new features.
2026-07-12 19:01:51 +00:00
harukin-expo-dev-env b5b5ceaa64 Merge commit '092590bc3db56548813b2f175a856a94bdccf938' into develop 2026-07-12 15:40:52 +00:00
harukin-expo-dev-env 092590bc3d 型定義を改善し、originalStationListの使用を最適化 2026-07-12 15:35:00 +00:00
harukin-expo-dev-env cffbeca659 Refactor MenuPage component and update dependencies
- Removed unused navigation logic from MenuPage.tsx.
- Added postinstall script for patch-package in package.json.
- Introduced a new patch for @react-navigation/bottom-tabs to fix zIndex and opacity issues on Android.
- Updated yarn.lock with new dependencies and versions, including patch-package and others.
2026-07-12 10:59:40 +00:00
harukin-expo-dev-env e5c90f6522 ネイティブスクリーンの無効化を条件化し、スクリーンの非アクティブ状態を保持する設定を追加 2026-07-11 04:27:16 +00:00
harukin-expo-dev-env 3458141784 Merge commit '1032dd0e188c506e4000ee1bcdebe17b3df14716' into develop 2026-07-11 00:14:03 +00:00
harukin-expo-dev-env 1032dd0e18 iOSおよびAndroidのビルド番号とバージョンコードを更新 2026-07-10 11:33:25 +00:00
harukin-expo-dev-env b3bf7e66e9 Merge commit '70df73bf3323c61571f4b94a7e579d70fc686fc9' into patch/6.x 2026-07-10 11:30:13 +00:00
harukin-expo-dev-env 70df73bf33 Merge commit '9fe6f3ebf30db4ded18d3689d297aeec5892bbcd' into develop 2026-07-10 11:28:33 +00:00
harukin-expo-dev-env 9fe6f3ebf3 履歴管理機能を追加し、ビューポートメタタグの管理を強化 2026-07-10 11:28:15 +00:00
harukin-expo-dev-env 3f6b9da6c5 Adjust train type badge rendering 2026-07-09 10:15:49 +00:00
harukin-expo-dev-env 3ead0cbe10 各種機能の改善と最適化、ナビゲーションの明示的なターゲット設定を追加 2026-07-08 14:39:15 +00:00
harukin-expo-dev-env d4db82cc65 各種データの取得フラグ機能を実装、動作の最適化 2026-07-08 01:39:29 +00:00
harukin-expo-dev-env 873acde54b Tune data fetch observability after first run 2026-07-07 04:10:24 +00:00
harukin-expo-dev-env a6fa5dfadb Add data fetch observability 2026-07-07 02:47:13 +00:00
harukin-expo-dev-env 9efe9475bf Work around Android user location map crash 2026-07-06 17:44:15 +00:00
harukin-expo-dev-env f981d82bed Initialize root tab state on startup 2026-07-06 08:42:28 +00:00
harukin-expo-dev-env f8f53b34d5 Tighten hidden tab preload timing 2026-07-06 08:31:08 +00:00
harukin-expo-dev-env ab75738344 Add hidden webview startup preload 2026-07-06 08:25:25 +00:00
harukin-expo-dev-env ac37033eb7 Run hidden prewarm without interaction gate 2026-07-06 07:50:39 +00:00
harukin-expo-dev-env 5bb1f395f6 Prewarm operation tab from top menu startup 2026-07-06 07:01:06 +00:00
harukin-expo-dev-env 25cefd5c69 Restore Android carousel parallax mode 2026-07-06 06:20:13 +00:00
harukin-expo-dev-env 4bb00abf3a Restore Android carousel and add runtime flags 2026-07-06 05:58:03 +00:00
harukin-expo-dev-env aa6e51cad7 Disable iOS screen optimizations for webviews 2026-07-04 16:33:33 +00:00
harukin-expo-dev-env 7d0f86538f Stabilize root tab screen mounting 2026-07-04 16:26:59 +00:00
harukin-expo-dev-env 9cd1b53532 Stabilize positions webview retention on iOS 2026-07-04 16:22:35 +00:00
harukin-expo-dev-env 658c3f01a5 Guard iOS webview remount on tab blur 2026-07-04 15:57:04 +00:00
harukin-expo-dev-env 9ab877f73c Preserve operation webview and quiet noisy defaults 2026-07-04 15:48:37 +00:00
harukin-expo-dev-env 9a9705cf20 Stabilize background WebView retention 2026-07-04 15:45:01 +00:00
harukin-expo-dev-env 93806a274a Adjust WebView remount behavior for background tabs 2026-07-03 10:24:41 +00:00
harukin-expo-dev-env c3a8219270 factor code structure for improved readability and maintainability 2026-07-02 02:37:34 +00:00
harukin-expo-dev-env 34542c35c3 Merge commit 'dd0b4bf312f8f5d255f7cb5a0bc27d8bda43b5e9' into experimental/station-announce-tools 2026-07-01 12:11:25 +00:00
harukin-expo-dev-env dd0b4bf312 Merge commit 'bfe572ecfbe989d4a702eb957a3bdc66eff9d34d' into develop 2026-07-01 12:10:45 +00:00
harukin-expo-dev-env bfe572ecfb インポート機能の作成、録画システムのポジション最適化 2026-06-29 16:11:42 +00:00
harukin-expo-dev-env ad7c013704 Merge commit '49aa33cbf5fcfcc6ecdea1702f36dc9fd5a4083f' into develop 2026-06-29 14:43:20 +00:00
harukin-expo-dev-env 49aa33cbf5 Update signage view configuration 2026-06-29 14:42:44 +00:00
harukin-expo-dev-env b783157553 Add recording JSON import and export 2026-06-29 14:39:30 +00:00
harukin-expo-dev-env d89e875459 Sentryロギングシステムを追加し、アプリのエラートラッキングを強化 2026-06-29 06:08:29 +00:00
harukin-expo-dev-env fed1044089 Voicepeak APIの案内音声再生タイミングを変更 2026-06-29 04:51:21 +00:00
harukin-expo-dev-env 2220f0c524 Merge commit '5efa63254d2c0e1d8090b14190b2ed43b9ed4937' into experimental/station-announce-tools 2026-06-27 06:45:04 +00:00
harukin-expo-dev-env 5efa63254d Merge commit 'b962330c863c0487df4cc50c31eef8a4f8a6d686' into develop 2026-06-27 04:37:27 +00:00
harukin-expo-dev-env b962330c86 Merge commit 'a589438e74dec12d2d97a7fd0bc06f624e7b2277' into patch/6.x 2026-06-27 04:35:48 +00:00
harukin-expo-dev-env a589438e74 要素のサイズ変更 2026-06-27 04:33:54 +00:00
harukin-expo-dev-env 3c5b403fb5 Merge commit '65f248f658482f2aa64fb0246a33ea7d19c72bad' into experimental/station-announce-tools 2026-06-25 17:47:42 +00:00
harukin-expo-dev-env 65f248f658 Merge commit '3d19b85915f4f014a4239573393f6a147c103e43' into develop 2026-06-25 17:46:42 +00:00
harukin-expo-dev-env 3d19b85915 7.0.7 release 2026-06-25 17:36:48 +00:00
harukin-expo-dev-env 846c131b70 Merge commit 'c7cabd74aedc600112314ef500bd2bcae1805a8b' into patch/6.x 2026-06-25 17:35:52 +00:00
harukin-expo-dev-env c7cabd74ae Merge commit '58c4a83d61d94a675c3c4318e1ff4e7521561824' into develop 2026-06-25 17:16:17 +00:00
harukin-expo-dev-env 58c4a83d61 小見出しも並べられるように変更 2026-06-25 16:57:20 +00:00
harukin-expo-dev-env 957ac9cf01 Hubアイコンを権限餅専用に 2026-06-25 16:20:04 +00:00
harukin-expo-dev-env 497265c5f9 団体臨時から臨時を削除 2026-06-25 16:01:31 +00:00
harukin-expo-dev-env 8abf6de68f Merge commit '11b73b3971f06d5e2d8273759b39429c8c4d427e' into develop 2026-06-25 14:27:38 +00:00
harukin-expo-dev-env 11b73b3971 切り替えスイッチを設置 2026-06-25 14:27:24 +00:00
harukin-expo-dev-env 99d8ef1540 レイアウト機能調整 2026-06-25 13:26:51 +00:00
harukin-expo-dev-env 2b0735563b Merge commit '5956d247b1796a4b6d1f907a2f70f2da8e92135a' into feature/train-info-edit 2026-06-23 17:12:37 +00:00
harukin-expo-dev-env 5956d247b1 Merge commit '28950019c67674d2585ee9af75d44ae09412857c' into develop 2026-06-23 17:12:31 +00:00
harukin-expo-dev-env 28950019c6 アイコンステータスの表示改善 2026-06-23 17:11:28 +00:00
harukin-expo-dev-env c44bad5f86 unyohubアイコン対応 2026-06-23 16:40:44 +00:00
harukin-expo-dev-env 8d03514fa2 feat: add userscript generator for JR Shikoku operation info
- Implemented a script to generate a Tampermonkey userscript from ndView.tsx.
- Extracted and modified the operation page script body to include necessary postMessage functions.
- Added metadata for the userscript including name, namespace, version, and match URL.
- Ensured compatibility with Tampermonkey's GM_download for image capture.
2026-06-20 06:07:39 +00:00
harukin-expo-dev-env 835a7d431e feat: 列車情報のキャプチャ機能を追加し、スタイルを改善 2026-06-10 05:04:09 +00:00
harukinMBP d27b75cbd3 feat: Voicepeak発車案内機能の強化とネイティブオーディオサポートの追加 2026-06-06 17:47:29 +09:00
harukinMBP 08147b7f26 feat: Voicepeak発車案内機能の追加と設定管理の実装 2026-06-06 17:47:29 +09:00
harukin-expo-dev-env 2dae129b0a Merge commit 'e87a348235ed7ea5b1faf0848be2b45f5d6db783' into develop 2026-06-06 08:35:26 +00:00
harukin-expo-dev-env e87a348235 fix: バージョンコードを7.0.6に更新 2026-06-05 12:48:19 +00:00
harukin-expo-dev-env 15d8b78d38 Merge commit '3b3d5d0ed6aafe10c9558afdde06b93272777bef' into patch/6.x 2026-06-05 12:35:07 +00:00
harukin-expo-dev-env 3b3d5d0ed6 feat: えれサイトの列車情報をグループ化し、表示を改善 2026-06-05 12:31:54 +00:00
harukin-expo-dev-env 868cf4f2f9 feat: 録画の削除機能を追加し、スワイプ操作での削除を実装 2026-06-01 12:11:00 +00:00
harukin-expo-dev-env e7ff5f81cf Merge commit 'ce4172d3275fca4a3b7327327d1ce7363ba28407' into develop 2026-05-20 15:29:09 +00:00
harukin-expo-dev-envandCopilot ce4172d327 fix: マリンライナー編成順序の修正(elesite連携)
マリンライナー(3xxxM)の岡山〜高松間の駅シーケンスを
elesiteTrainOrder.ts と webViewInjectjavascript.ts に追加。
originData には JR 西日本区間の駅がないため、
MARINE_STATION_SEQUENCE としてインラインで定義した。

これにより heading_to:"unknown" のマリン系統でも
正しく進行方向を推論できるようになり、
連結順序が逆順になる問題を解消した。

Co-authored-by: Copilot <[email protected]>
2026-05-20 13:48:47 +00:00
harukin-expo-dev-env 024b39b3c3 Merge commit 'adec3db4a5b755beb19f47688d614839295275b1' into develop 2026-05-20 06:18:08 +00:00
harukin-expo-dev-env adec3db4a5 fix: update version code to 7.0.5 in SettingTopPage component 2026-05-20 06:17:56 +00:00
harukin-expo-dev-env c59eac0a82 feat: add button for paper timetable in FixedContentBottom component 2026-05-20 06:02:50 +00:00
harukin-expo-dev-env 86142d6679 Merge commit '48a078f831b19041f7b3d0cf839bd9aa126eb139' into patch/6.x 2026-05-19 03:07:03 +00:00
harukin-expo-dev-env 48a078f831 feat: add experimental diagram URL support and update related components 2026-05-19 03:06:45 +00:00
harukin-expo-dev-env 0ace931f1a Merge commit '17565da7ab08e400d97253ca82000a27ae82dcc3' into patch/6.x 2026-05-15 17:22:28 +00:00
harukin-expo-dev-env 17565da7ab Refactor Elesite sorting logic and introduce new utility functions
- Moved the Elesite sorting logic to a new module `elesiteTrainOrder.ts`.
- Created utility functions to infer heading direction and sort Elesite entries by train number.
- Updated `injectJavascriptData` to utilize the new sorting logic and handle station sequences.
- Refactored `useElesite` to use the new sorting utility for improved readability and maintainability.
2026-05-15 17:21:18 +00:00
harukin-expo-dev-env 0f73d48964 Merge commit '7c838f073b9a6fc4d032b280949038ee4448c7c2' into develop 2026-05-05 10:43:58 +00:00
harukin-expo-dev-env 7c838f073b feat: add Koboke Observatory to tourist spots data 2026-05-05 09:10:34 +00:00
harukin-expo-dev-env ee4d5c69ce fix: update version code to 7.0.4 2026-05-05 08:12:25 +00:00
harukin-expo-dev-env c2e1e03b47 Merge commit 'eb0ca2ead4208217d03de43a5a76563cb0198a40' into patch/6.x 2026-05-05 08:10:22 +00:00
harukin-expo-dev-env eb0ca2ead4 Merge commit 'a112f3947b6cd5e81802a16db454861a734cd8b7' into develop 2026-05-05 08:09:57 +00:00
harukin-expo-dev-envandCopilot a112f3947b feat: purple reload button when mock API is active
Co-authored-by: Copilot <[email protected]>
2026-05-05 08:06:55 +00:00
harukin-expo-dev-env 89a603183f Merge commit '86092b33dec60a39ef971fddb24f0898f48d88ad' into develop 2026-05-05 08:04:56 +00:00
harukin-expo-dev-env 86092b33de Add script to compile web inject JavaScript with minification and obfuscation options
- Introduced `compile-web-script.ts` for generating JavaScript files for userscripts.
- Supports options for minification and obfuscation.
- Generates base JS, minified JS, and obfuscated userscript.
- Configurable via command line arguments and environment variables.
2026-05-05 08:04:47 +00:00
harukin-expo-dev-envandCopilot 1c5fbca65d fix: use /train-positions/current endpoint for mock polling
Co-authored-by: Copilot <[email protected]>
2026-05-03 18:25:47 +00:00
harukin-expo-dev-envandCopilot a59faa9341 feat: poll mock API server for live train positions
- positionMasters: add fetchMockTrainPositions() targeting
  /train-positions/latest on the mock API server
- useTrainMenu: when mockApiFeatureEnabled and not recording/playing,
  poll /train-positions/latest every 15s and update mockTrainPositions
  (same interval as live mode); on mode switch updates immediately
- useCurrentTrain:
  - playing: use local recording data (no network call)
  - mock ON idle: fetch from mock API server (15s poll driven by useTrainMenu)
  - mock OFF: fetch from real backend as before
- Recording still forces mock OFF so real API data is captured

Co-authored-by: Copilot <[email protected]>
2026-05-03 18:01:12 +00:00
harukin-expo-dev-envandCopilot a4416de1bc feat: integrate position-masters API into mock system
- Add lib/mockApi/positionMasters.ts
  - fetchPositionMasters() from jr-shikoku-backend-mock-api-v1.haruk.in
  - buildPosLookup() / lookupPos() helpers
  - serializePosLookupForJs() for baking into injected scripts
- webviewXhrInterceptor: accept positionMasters in MockApiConfig,
  bake _POS_LOOKUP into injected JS, enrich Pos from PosNum on serve
- useTrainMenu: fetch position masters on mock-enable (and at startup
  if already enabled); expose positionMasters + lookupPosText()
- useCurrentTrain: fill Pos via lookupPosText when mapping mock data
- mockApi/index.ts: re-export positionMasters types and helpers

Co-authored-by: Copilot <[email protected]>
2026-05-03 17:46:13 +00:00
harukin-expo-dev-env fb9ee53f79 Merge commit '5f98f62d5dca7a6ab0847b0a9c01c74fc25b106d' into develop 2026-05-02 09:15:31 +00:00
harukin-expo-dev-env 5f98f62d5d feat: add detailed changelog for version update to 7.0.3, highlighting new features and improvements 2026-05-02 08:59:55 +00:00
harukin-expo-dev-env 471923c881 Merge commit '2283b71c8411c1accb1e0581d9a4c3f95555e88e' into develop 2026-05-02 03:01:15 +00:00
harukin-expo-dev-env 2283b71c84 fix: update version code to 7.0.3 2026-05-02 02:59:29 +00:00
harukin-expo-dev-env 8fa6888367 Merge commit 'bd1b1f15c0ab24d485b3a3389a1a568b034b2574' into patch/6.x 2026-05-02 02:53:10 +00:00
harukin-expo-dev-env bd1b1f15c0 fix: update header colors in injected JavaScript for better visibility 2026-05-02 02:52:22 +00:00
harukin-expo-dev-envandCopilot 922add003a fix: sync WebView train display when playback frame changes
The XHR interceptor baked _MOCK_TRAIN at page load time, so seeking or
advancing a playback frame only changed React state but had no effect
on the already-loaded WebView page.

- webviewXhrInterceptor.ts:
  - Expose window.__jrsMockUpdateTrain(newData) from inside the IIFE so
    the _MOCK_TRAIN variable can be updated after page load
  - Add generateMockUpdateScript(trainPositions) helper: calls
    __jrsMockUpdateTrain with fresh data + GetDateTime, then calls
    window.setReload() to trigger the page's own redraw cycle

- WebView.tsx:
  - Import generateMockUpdateScript
  - Add useEffect watching mockTrainPositions (skips first mount since
    beforeContentLoaded already has correct data)
  - When mockApiFeatureEnabled && mockTrainPositions changes, inject the
    update script via webview.current.injectJavaScript()

Result: seeking, prev/next frame, and auto-advance during playback now
immediately update the train position display in the WebView.

Co-authored-by: Copilot <[email protected]>
2026-05-02 01:06:34 +00:00
harukin-expo-dev-envandCopilot 71c5133ad1 feat: add RecordingStatusBar with elapsed timer and keep-awake
- RecordingStatusBar.tsx: shown during recorderState === 'recording'
  - absolute positioned red bar at top of map screen (zIndex 2000)
  - blinking REC dot (700ms interval) + REC label + MM:SS elapsed timer
  - snapshot count display (right side)
  - pointerEvents="none" so it doesn't block map interaction
  - activateKeepAwakeAsync while recording, deactivated on stop/unmount
    (same pattern as FixedPositionBox, tag = 'recording-status-bar')
- Apps.tsx: render <RecordingStatusBar /> alongside PlaybackTimeline

Co-authored-by: Copilot <[email protected]>
2026-05-02 00:59:03 +00:00
harukin-expo-dev-envandCopilot 77f15345e5 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 <[email protected]>
2026-05-02 00:52:30 +00:00
harukin-expo-dev-envandCopilot 43dcaab643 feat: add playback timeline UI with pause/resume/seek controls
- useTrainMenu: add playbackPaused state, pausePlayback/resumePlayback/seekToSnapshot
  - playback loop now respects playbackPaused (no timer when paused)
  - seekToSnapshot pauses playback and immediately applies snapshot data
  - expose playbackIndex, playbackPaused + new functions in context value
- PlaybackTimeline.tsx: new component shown when recorderState === 'playing'
  - absolute positioned bar at top of map screen (zIndex 2000)
  - prev/play-pause/next frame buttons + skip-to-start/end
  - time display (HH:mm:ss) + snapshot counter (n/total) + total duration
  - PanResponder-based scrubber track with filled progress bar and draggable thumb
- Apps.tsx: render <PlaybackTimeline /> alongside FixedPositionBox
- Remove MockApiToggle.tsx (no longer used since settings-only toggle)

Co-authored-by: Copilot <[email protected]>
2026-05-02 00:42:56 +00:00
harukin-expo-dev-envandCopilot 597151e875 feat: add train position record & playback feature
Implements snapshot-based recording and playback of live train
position data for mock API debugging (admin-only).

Recording:
- startRecording() disables mock mode and begins capturing live
  train snapshots every ~15s (via getCurrentTrain polling)
- Each snapshot stores { t: elapsed_ms, trains: TrainEntry[] }
- stopRecording() saves completed recording to AsyncStorage

Playback:
- startPlayback() enables mock mode and loops through snapshots
  at their original recorded timing using useEffect+setTimeout
- stopPlayback() returns to idle

Storage:
- Single slot in AsyncStorage (MOCK_RECORDING key)
- Loaded on app start in useTrainMenu
- deleteRecording() removes it

New files:
- lib/mockApi/trainRecorder.ts — TrainRecording/TrainSnapshot types
  + save/load/delete AsyncStorage helpers

Modified files:
- constants/storage.ts — add MOCK_RECORDING key
- stateBox/useTrainMenu.tsx — recorder state + all controls
- stateBox/useCurrentTrain.tsx — call addTrainSnapshot after live fetch
- components/Settings/DataSourceSettings.tsx — record/play UI with
  status dot, snapshot count, and action buttons

Co-authored-by: Copilot <[email protected]>
2026-05-02 00:31:55 +00:00
harukin-expo-dev-env 224ce83abf fix: update useWebViewRemount to include backgroundThresholdMs option for better app state handling 2026-05-01 23:25:50 +00:00
harukin-expo-dev-envandCopilot b3bb7fef72 feat: apply mock data to currentTrain (app-side train positions)
When MOCK_API_FEATURE_ENABLED is on, getCurrentTrain() now returns
mock data instead of fetching from the n8n webhook or fallback API.

- App.tsx: move TrainMenuProvider before CurrentTrainProvider so
  useTrainMenu() is available inside CurrentTrainProvider
- useCurrentTrain: import useTrainMenu + MOCK_TRAIN_POSITIONS;
  getCurrentTrain() short-circuits to mock data when mockApiFeatureEnabled;
  added useEffect on mockApiFeatureEnabled to reload immediately on toggle

Co-authored-by: Copilot <[email protected]>
2026-05-01 12:40:30 +00:00
harukin-expo-dev-envandCopilot 1176234c08 refactor: remove map-screen MOCK switch; settings toggle controls mock directly
The separate runtime MOCK switch on the map screen is removed.
Now the admin-only settings toggle (MOCK_API_FEATURE_ENABLED) is the
single control point — turning it on activates mock mode immediately,
turning it off deactivates it.

- useTrainMenu: remove mockApiEnabled/setMockApiEnabled state;
  mockApiConfig now derives from mockApiFeatureEnabled alone
- WebView: use mockApiFeatureEnabled for key prop (triggers reload)
- Apps.tsx: remove MockApiToggle import and JSX usage

Co-authored-by: Copilot <[email protected]>
2026-05-01 12:35:36 +00:00
harukin-expo-dev-envandCopilot 85b0eb5b58 fix: eliminate UX flash by calling setStrings() immediately in observer
The 100ms pure-debounce approach caused a visible layout flash on every
render (normal mode too) because Tokyo UX was not applied for 100ms.

Fix: call setStrings() immediately in the MutationObserver callback to
prevent any flash, AND keep a 250ms delayed follow-up call to catch
async train re-renders from mock XHR setTimeout(0) responses.

Co-authored-by: Copilot <[email protected]>
2026-05-01 11:59:52 +00:00
harukin-expo-dev-envandCopilot 8885a70792 fix: re-apply Tokyo UX after async train re-renders
Root cause: setStrings() was called synchronously in setReload(),
but the mock XHR interceptor responds via setTimeout(0), so the
site re-rendered train elements AFTER setStrings() ran, stripping
Tokyo UX styling.

Two-pronged fix:
1. setReload() now calls setStrings() again after 200ms to
   re-apply Tokyo UX once the async re-render completes.
2. textInsert MutationObserver now watches with subtree:true
   (childList only, not attributes - no infinite loop risk)
   with 100ms debouncing, so ANY train element re-render
   (including those triggered by mock XHR data) will trigger
   a re-application of Tokyo UX.
   - currentLines postMessage now only fires when direct
     children of #disp change (line selection changes only)

Co-authored-by: Copilot <[email protected]>
2026-05-01 11:51:13 +00:00
harukin-expo-dev-envandCopilot c6aecd151c fix: Tokyo UX stripped when mock is active
Two bugs caused the Tokyo UX to be stripped/broken when mock mode was ON:

1. **Double callback firing**: the interceptor's `setTimeout` called both
   `onreadystatechange.call(self)` AND `dispatchEvent(new Event('readystatechange'))`.
   Since `dispatchEvent` already fires `onXxx` property handlers via the DOM event
   model, the page's callback fired twice, causing a second DOM re-render that could
   overwrite Tokyo UX modifications. Fixed by relying on `dispatchEvent` only (with
   `ProgressEvent` for load events), with a direct-call fallback only for environments
   that lack `dispatchEvent`.

2. **No double-injection guard**: if `injectedJavaScriptBeforeContentLoaded` caused
   the interceptor to run more than once (e.g., `onPageStarted` fires multiple times
   on Android), `_origOpen` would capture the already-patched version, leading to
   unexpected behaviour. Fixed by adding `if (window.__jrsMockActive) return;` at the
   top of the interceptor IIFE.

3. **Belt-and-suspenders injection**: the interceptor is now also prepended to
   `injectedJavaScript` (Tokyo UX script) via the restored `mockApiConfig` parameter
   on `injectJavascriptData`. The `__jrsMockActive` guard ensures it's a no-op when
   `injectedJavaScriptBeforeContentLoaded` already ran it, but guarantees the
   interceptor is active on platforms where IJBCL is unreliable — before
   `MoveDisplayStation` triggers the first train-data poll via `onLoadEnd`.

Co-authored-by: Copilot <[email protected]>
2026-05-01 11:40:11 +00:00
harukin-expo-dev-envandCopilot 8ee546277c chore: update train.json with live disrupted data (2026-05-01 19:42)
94列車、62列車遅延の運行乱れデータをキャプチャ。
モックAPIのテストデータとして有用な状態。

Co-authored-by: Copilot <[email protected]>
2026-05-01 10:43:19 +00:00
harukin-expo-dev-envandCopilot 547b5074db fix: call _origOpen even when intercepting to allow setRequestHeader
インターセプト対象のXHRでも_origOpen()を呼ぶよう修正。
これによりXHRがOPENED状態に入り、ページ側のsetRequestHeader()呼び出しが
エラーなく実行される。実際のリクエストはsend()でブロックすることで
モックデータのみ返す動作は維持される。

Playwright実機テスト結果:
- arg1=train&arg2=train: 2回インターセプト成功、実ネットワーク0件
- setRequestHeaderエラー: 解消
- 路線選択後のstation dataロード: 正常動作

Co-authored-by: Copilot <[email protected]>
2026-04-30 22:49:22 +00:00
harukin-expo-dev-envandCopilot 75fce29d74 fix: move XHR interceptor to injectedJavaScriptBeforeContentLoaded
インターセプターをページスクリプトより前に実行されるよう修正。
injectedJavaScript(ページ読込後)からinjectedJavaScriptBeforeContentLoaded
(ページスクリプト実行前)へ移動することで、モックON時のページフリーズを解消。

- lib/webViewInjectjavascript.ts: injectJavascriptDataからインターセプターを分離、
  generateBeforeContentLoadedScript()を新規エクスポート
- stateBox/useTrainMenu.tsx: injectJavascriptBeforeContentLoadedを別途計算しコンテキストに追加
- components/Apps/WebView.tsx: injectedJavaScriptBeforeContentLoadedプロップを追加

Co-authored-by: Copilot <[email protected]>
2026-04-30 16:50:55 +00:00
harukin-expo-dev-envandCopilot c31d35c099 fix: rewrite XHR interceptor using prototype patching
Replaces the MockXHR wrapper class with direct prototype patching.

Root cause of broken page:
1. CRITICAL: callbacks called without .call(self) → 'this' inside
   onload/onreadystatechange was the global object, so this.responseText
   returned undefined and the page's JSON.parse crashed silently
2. responseType='json' not handled → page received string instead of
   parsed object when using xhr.response
3. instanceof XMLHttpRequest returned false, potentially breaking page
   validation logic

New approach (prototype-patch):
- Save original XMLHttpRequest.prototype.open and .send
- Patch open() to detect mock URL and store mock body on the instance
- Patch send() to define instance-level property getters (status,
  responseText, response, readyState) and fire callbacks with correct
  'this' context via .call(self)
- responseType='json' handled: response getter returns parsed object
- instanceof XMLHttpRequest always true (prototype chain untouched)
- dispatchEvent fires load/readystatechange for addEventListener users
- Non-mock requests delegate to original open/send unchanged

All 6 test cases pass: onload, onreadystatechange, addEventListener,
responseType=json, instanceof, and non-mock pass-through

Co-authored-by: Copilot <[email protected]>
2026-04-30 16:05:41 +00:00
harukin-expo-dev-envandCopilot bce0c28a21 fix: reload WebView on mock toggle and fix XHR callback timing bug
- components/Apps/WebView.tsx:
  - import mockApiEnabled from context
  - add mockApiEnabled to WebView key so toggling forces a full reload
    (injectedJavaScript only runs at page load, so reload is required)

- lib/mockApi/webviewXhrInterceptor.ts:
  - Fix callback timing bug: callbacks set before open() were stored on
    _orig (because _mockBody was null), never on self._onload, causing
    mock send() to fire no-op callbacks
  - Always store all callbacks/listeners in _callbacks/_listeners maps
  - Apply _callbacks to _orig at send() time when not mocked
  - Fix addEventListener: always buffer in _listeners, apply to _orig
    at send() time; add removeEventListener support

Co-authored-by: Copilot <[email protected]>
2026-04-30 15:50:37 +00:00
harukin-expo-dev-envandCopilot 33e344c4c4 feat: add admin mock API toggle in settings and map screen switch
- constants/storage.ts: add MOCK_API_FEATURE_ENABLED key
- stateBox/useTrainMenu.tsx:
  - import MOCK_TRAIN_POSITIONS; auto-populate on mount and on toggle-off
  - add mockApiFeatureEnabled (persistent, admin-only) state + setter
  - mockApiConfig now requires both mockApiFeatureEnabled AND mockApiEnabled
- components/Settings/DataSourceSettings.tsx:
  - add mock API debug section (admin-gated, showDebugSelector)
  - Switch toggles mockApiFeatureEnabled and persists to AsyncStorage
- components/Apps/MockApiToggle.tsx: new component
  - absolute-positioned to the left of ReloadButton
  - visible only when mockApiFeatureEnabled && mapSwitch==="true"
  - MOCK label + Switch toggles mockApiEnabled runtime state
- components/Apps.tsx: render MockApiToggle alongside ReloadButton

Co-authored-by: Copilot <[email protected]>
2026-04-30 15:41:24 +00:00
harukin-expo-dev-envandCopilot eed51d31fa feat: add WebView XHR interceptor for mock train position injection
- Scrape & analyse all 19 internal APIs of train.jr-shikoku.co.jp/sp.html
  using puppeteer + Chromium remote debugging
- Save live-captured sample JSON for every API endpoint under
  lib/mockApi/mockData/ (station lists, lang, timetable, train positions, etc.)
- Add lib/mockApi/webviewXhrInterceptor.ts – generates a JS snippet that
  overrides XMLHttpRequest inside the WebView before page scripts run;
  intercepts /g?arg1=train&arg2=train (and optionally all static APIs)
  returning mock data instead of the real server response
- Add lib/mockApi/index.ts – convenience re-exports + MOCK_TRAIN_POSITIONS
  constant pre-populated with captured sample data
- Extend InjectJavascriptOptions with optional mockApiConfig field
- injectJavascriptData() prepends the interceptor JS when mockApiConfig is set
- TrainMenuContext gains mockApiEnabled / setMockApiEnabled and
  mockTrainPositions / setMockTrainPositions state; consumers can enable
  mock mode and inject custom TrainEntry[] without reloading the app

Co-authored-by: Copilot <[email protected]>
2026-04-30 15:30:06 +00:00
harukin-expo-dev-env 4d3176186f fix: add platformNum property to train data structures for improved display 2026-04-30 15:18:28 +00:00
harukin-expo-dev-env a1dabee82b fix: add loading and error handling in GeneralWebView component with reload functionality 2026-04-30 14:27:15 +00:00
harukin-expo-dev-env 1567468071 fix: expose setReload function to the global window object for accessibility 2026-04-30 14:01:55 +00:00
harukin-expo-dev-env 45f90b63bd fix: trim train numbers in sorting and filtering logic for accurate matching 2026-04-30 09:49:44 +00:00
harukin-expo-dev-env 7a8b6eda9d feat: add FormationChips, FadingSubCycler, and ActiveFormationChipsCycler components for enhanced train data visualization 2026-04-30 08:59:46 +00:00
harukin-expo-dev-envandCopilot 9f510dc846 fix: normalize train numbers by stripping suffixes in TrainDataSources and useUnyohub
Co-authored-by: Copilot <[email protected]>
2026-04-29 15:51:15 +00:00
harukin-expo-dev-env 71499126c2 Merge commit '77b563876dccc23ac7929ee6011ccabd77ecabbe' into develop 2026-04-29 11:15:58 +00:00
harukin-expo-dev-env 77b563876d feat: add changelog for version update from 064d81d4 to a959cf39, highlighting improvements in elesite integration, navigation stability, keyboard handling, and overall user experience 2026-04-29 11:15:52 +00:00
harukin-expo-dev-env 17af25ce67 Merge commit '2e158bc6d949aad9ebde66f7ec1f522bbd33aef4' into patch/6.x 2026-04-26 08:49:28 +00:00
harukin-expo-dev-env 2e158bc6d9 fix: simplify elesite permission handling and update version code to 7.0.2 2026-04-26 08:43:04 +00:00
harukin-expo-dev-env c929764bae Merge commit 'c2b3136c4003e4394a83ded6b30453092c5e5d72' into develop 2026-04-26 07:56:56 +00:00
harukin-expo-dev-env c2b3136c40 fix: enhance ListViewItem cycling animation and update train pair mapping in BusAndTrainDataProvider 2026-04-25 07:34:10 +00:00
harukin-expo-dev-env 3cce9c1cf9 Merge commit 'bfdbef6e6f360e6eafb3a04b0982d510e52e40c2' into fix/April-Mid-Patch 2026-04-19 11:09:20 +00:00
harukin-expo-dev-env bfdbef6e6f fix: update WebView navigation and adjust interval timing in CurrentTrainProvider 2026-04-19 11:09:13 +00:00
harukin-expo-dev-env 32238ee557 Merge commit '5112345dd04cf76eb83af71ba2ae6a4a783ac4db' into experiment/web-url-experimental 2026-04-14 13:55:43 +00:00
harukin-expo-dev-env 5112345dd0 fix(HeaderText): update todayOperation prop to filter out completed operations 2026-04-14 13:55:37 +00:00
harukin-expo-dev-env ea20437a8a Merge commit '990e6f4a0490b864fa124e2d402088c5dd9514af' into experiment/web-url-experimental 2026-04-13 23:54:29 +00:00
harukin-expo-dev-env 990e6f4a04 Add new logo image for Elesite to relationLogo assets 2026-04-13 07:47:25 +00:00
harukin-expo-dev-env 23f757ff04 Merge commit 'a19b249e01a1aca838b4d4f2a0dcfdcb91069638' into experiment/web-url-experimental 2026-04-12 11:18:09 +00:00
harukin-expo-dev-env a19b249e01 fix(ExGridView): remove zoom scale properties from Animated.ScrollView 2026-04-12 10:02:00 +00:00
harukin-expo-dev-env 01e6735e6d Merge commit 'ea244225adcea0509c716b94c00a411fd12140c1' into experiment/web-url-experimental 2026-04-12 08:19:06 +00:00
harukin-expo-dev-env ea244225ad fix(TrainDataView): update onLongPress condition to check currentTrainData instead of onLine 2026-04-12 08:18:59 +00:00
harukin-expo-dev-env a76a227880 fix(HeaderText): update todayOperation to use allTodayOperation for accurate state filtering
fix(TrainIconStatus): add cache option to fetch request for improved data handling
2026-04-12 08:18:33 +00:00
harukin-expo-dev-env 97bc435fa0 feat: 投稿システム接続先のデバッグ機能を追加し、環境設定を管理できるようにした 2026-04-11 04:13:45 +00:00
harukin-expo-dev-env a1de6d8197 fix: タブバーのアニメーションとキーボード非表示設定を削除
fix: JRSTraInfoコンポーネントの初期データ読み込み処理をuseEffectで追加
2026-04-10 09:36:09 +00:00
harukin-expo-dev-envandCopilot 8e55d32175 fix(SearchUnitBox): use animatedOffset with Animated.View for smooth keyboard avoidance
- Replace measuredOffset (plain number) with animatedOffset (Animated.Value)
  so the search bar smoothly follows the keyboard instead of jumping abruptly
- Wrap position:absolute container in Animated.View to accept Animated.Value as bottom
- Remove LayoutAnimation.configureNext calls that conflicted with Animated.timing
  from useKeyboardAvoid, causing layout animation races on Android
- Drop unused keyboardHeight guard (keyboardHeight > 0 ? measuredBottom : 0);
  animatedOffset starts at 0 and is driven by the hook's timing, so no jump

Co-authored-by: Copilot <[email protected]>
2026-04-10 02:30:46 +00:00
harukin-expo-dev-env 867317bd66 docs: キーボードアニメーション調整に関するドキュメントを追加 2026-04-09 10:46:54 +00:00
harukin-expo-dev-env c12478c641 fix: キーボード回避をAnimated.timing/springに移行し、高速切替時の位置ずれとアニメーション不動を解消
- LayoutAnimation.configureNext → Animated.timing/spring に全面移行
  - iOS: Animated.spring でキーボードアニメーションに追従
  - Android: Animated.timing + Easing.out(cubic) で自然な減速カーブ
- measureGenRef 世代カウンタで飛行中の古い measure() コールバックを無効化
- retryTimerRef (500ms) で adjustResize の中間座標を自動訂正
- currentAnimRef で高速切替時に前アニメをキャンセル
- AllTrainDiagramView / StationDiagramView を Animated.View 化
- docs: 改修資料を追加
2026-04-09 10:41:19 +00:00
harukin-expo-dev-env 6240b07230 feat: チュートリアル機能の設計案を追加し、ユーザーの初回体験を改善 2026-04-08 05:20:52 +00:00
harukin-expo-dev-env ff05a2ba72 docs: ActionSheetアニメーション破綻の修正記録を追加 2026-04-08 05:03:19 +00:00
harukin-expo-dev-env a1830ae94a fix: EachTrainInfo ActionSheetのスプリングアニメーション破綻を修正
iOS (isModal=true) でマリンライナー等の走行中列車を表示した際に
ActionSheet のスライドアップアニメーションが瞬間表示になる問題を修正。

【根本原因】
1. iOS onOpen の発火タイミング問題(最重要)
   - ライブラリ内で onOpen が Modal.onShow にバインドされており、
     スプリングアニメーション開始「前」に発火する
   - onOpen 後に showThrew=true になると通過駅が追加されて高さが増加し
     onSheetLayout が再発火 → スプリングがほぼ終点からリスタート

2. useEffect による非同期な高さ変化
   - useThroughStations / useStopStationIDs / useTrainDiagramData が
     useState([]) で初期化し useEffect で計算していたため
     空リスト → フルリストの高さ変化が onSheetLayout をトリガーしていた

3. useAutoScroll の InteractionManager が Reanimated アニメーションを認識しない

【修正内容】
- EachTrainInfoCore: showThrew の初期値を useState(() => !!getCurrentStationData(...))
  に変更し、走行中なら最初から true にして高さ変化を防ぐ
- useTrainDiagramData / useThroughStations / useStopStationIDs:
  純粋計算関数を抽出し useState lazy initializer で初回レンダリング時から正確な高さを確保
- EachTrainInfo: onOpen/onClose で sheetOpened state を管理し EachTrainInfoCore に渡す
- useAutoScroll: setShowThrew 引数を削除、sheetOpened フラグでスクロールをゲート
2026-04-08 05:00:58 +00:00
harukin-expo-dev-env 504ed87ad3 stackAwareNavigate関数を導入し、遷移時のナビゲーションロジックを改善 2026-04-08 02:54:30 +00:00
harukin-expo-dev-env cb6930afe8 ナビゲーションロジックを改善し、stackAwareNavigate関数を導入して遷移時のスタック管理を強化。プライバシーポリシーと設計メモを追加。 2026-04-08 02:54:14 +00:00
harukin-expo-dev-env 1aa3412060 AppContainerのonStateChangeロジックを改善し、アクティブなルートの状態を正確にチェックするように修正 2026-04-05 06:07:17 +00:00
harukin-expo-dev-env d93b211bcc StatusBarの表示ロジックを改善し、Appsコンポーネントにフォーカス状態を追加 2026-04-05 06:07:04 +00:00
harukin-expo-dev-env b0a19ebf49 運用Hub情報の取得ロジックを改善し、貨物列車の車番処理を追加 2026-04-03 02:07:26 +00:00
harukin-expo-dev-env ea2dcbc19a 噂機能のスタイル強化 2026-04-02 15:25:22 +00:00
harukin-expo-dev-env 138357fa6b Merge commit 'fca404878dab04a60ea56a42f066710e847173f0' into develop 2026-04-02 15:24:27 +00:00
harukin-expo-dev-env fca404878d MapPinコンポーネントにおけるマーカーの表示方法を改善し、画像をViewでラップしてスタイルを適用 2026-04-01 11:13:54 +00:00
harukin-expo-dev-env 6b9f5634f2 バージョンコードを7.0から7.0.1に更新 2026-04-01 10:23:36 +00:00
harukin-expo-dev-env d5da2518c5 各コンポーネントにおけるLive Activity機能の一時的無効化、アイコン設定の改善、アイコンリストのセクション化、及び関連する状態管理の追加 2026-04-01 10:14:49 +00:00
harukin-expo-dev-env 4548f825a7 アセット画像と変更履歴を追加 2026-04-01 10:08:10 +00:00
harukin-expo-dev-env 0e57b0bf50 Merge commit 'd21d9adb6179fb0880649439c516ed7a3817940c' into patch/6.x 2026-03-31 17:42:19 +00:00
harukin-expo-dev-env d21d9adb61 iOSビルド用設定変更 2026-03-31 17:39:24 +00:00
harukin-expo-dev-env 0479a50246 Live Activity機能を一時的に無効化し、関連するコードを修正 2026-03-31 17:34:59 +00:00
harukin-expo-dev-env 1f57ada2ec iOSビルド番号を61から62に更新 2026-03-31 15:01:59 +00:00
harukin-expo-dev-env 5a74f8e0c3 Merge commit '0189cef8ef3735b1f88d5c5e734a0607a69ebce1' into develop 2026-03-31 15:01:27 +00:00
harukin-expo-dev-env 0189cef8ef ウィジェットのテーマカラーを追加し、各ウィジェットでの色の適用を改善 2026-03-31 14:59:47 +00:00
harukin-expo-dev-env 215a700497 SettingTopPageとSoundSettingsのリファクタリング、不要なコードの削除とオーディオプレイヤー機能の追加 2026-03-31 11:17:19 +00:00
harukin-expo-dev-env 862c8bd196 アイコン設定画面のリファクタリングとローディングアニメーションの追加 2026-03-31 06:56:35 +00:00
harukin-expo-dev-env 75b88b6a7a アプリアイコンシステムガイドを追加し、アイコンの追加手順と規格を明確化 2026-03-31 03:32:49 +00:00
harukinMBP dbe9b784fa iOSウィジェットにURLを追加し、ユーザーがトレイン情報と運行情報にアクセスできるようにしました 2026-03-31 12:10:07 +09:00
harukin-expo-dev-env d0768274d2 アイコンのIDの大文字小文字を統一し、可読性を向上 2026-03-31 02:20:44 +00:00
harukin-expo-dev-env 85b4b8a8f2 テキストスタイルにテーマカラーを適用し、可読性を向上 2026-03-31 02:15:14 +00:00
harukin-expo-dev-env ab157e4928 Add new PNG icons for various assets
- Introduced multiple new PNG icons in the assets/icons directory, including:
  - s40.png
  - s54nany1.png
  - s54nany2.png
  - s54s.png
  - s54to0ys.png
  - s6000f.png
  - s7001.png
  - s8001nr.png
  - s9000.png
  - tosa9640ht.png
  - tosa9640jbl.png
  - tosa9640mo1.png
  - tosa9640mo2.png
  - tosa9640tyb.png
  - tosa9640tyg.png
  - w141jg.png
  - w40.png
  - w741.png

These icons are essential for enhancing the visual representation of the application.
2026-03-31 01:50:19 +00:00
harukin-expo-dev-env 31edf18b82 useThemeColorsを使用してダークモードの判定を統一し、コードの可読性を向上 2026-03-31 00:21:38 +00:00
harukin-expo-dev-env 19959c52d6 useThemeColorsからisDarkを追加し、ユーザーインターフェーススタイルを動的に設定 2026-03-31 00:01:07 +00:00
harukin-expo-dev-env 86713d2647 WebViewコンポーネントにcontentMode="mobile"を追加し、表示を最適化 2026-03-30 23:45:31 +00:00
harukin-expo-dev-env 0d25251f91 画面遷移アニメーション完了後に重たい計算を実行するためのフラグを追加し、ローディングアニメーションを実装 2026-03-30 12:51:12 +00:00
harukin-expo-dev-env 61a9a71bc6 TextInputコンポーネントのプレースホルダーのテキストカラーをcolors.textTertiaryに変更 2026-03-30 11:07:23 +00:00
harukin-expo-dev-env e36a515a2b 各コンポーネントのアニメーション最適化とリファクタリング: useSharedValueとuseAnimatedStyleを導入し、パフォーマンスを向上 2026-03-30 10:11:14 +00:00
harukin-expo-dev-envandClaude Opus 4.6 7ea56b160f ActionSheetアニメーション阻害の修正: LayoutAnimation削除とブリンクアニメーション最適化
- ActionSheet内外のLayoutAnimation.configureNext/easeInEaseOutを削除
  (グローバルにレイアウト更新を乗っ取り、シートの開閉アニメーションと競合するため)
- trainIconStatus: reanimated withRepeat→useInterval+setStateに変更
  (UIスレッドの無限アニメーションがActionSheetスプリングと競合するため)
- EachTrainInfo: Rules of Hooks違反修正(useRef/useSheetMaxHeightを条件分岐前に移動)
- useAutoScroll: InteractionManager.runAfterInteractionsでシート展開完了後に実行

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-30 10:09:45 +00:00
harukin-expo-dev-env 916f8ba83e TrainDataSources: 列番上書き時にヘッダーでくるくるアニメーション表示 2026-03-30 10:00:20 +00:00
harukin-expo-dev-env 577e0487d4 アイコンのリファクタリング: hub_logo.pngとicon_2048.webpを削除し、elesite_logo.pngとunyohub_logo.webpを追加 2026-03-30 07:21:07 +00:00
harukin-expo-dev-env 6d66a7bdd2 HeaderText: useMemoフックにtrainNumとallCustomTrainDataを追加して依存関係を最適化
TrainIconStatus: アンパンマンステータスAPIのエンドポイント判定を追加
webViewInjectjavascript: 新しい列車アイテムの最大幅を設定
2026-03-30 05:54:25 +00:00
harukin-expo-dev-env 9155e03ba4 各コンポーネントでのuseMemoフックの追加とデータ処理の最適化 2026-03-30 04:00:25 +00:00
harukin-expo-dev-env bc5352ea56 Merge commit '07a9343ca66a42b2da936cb0df48370528f38973' into develop 2026-03-30 02:27:24 +00:00
harukin-expo-dev-env 07a9343ca6 EachTrainInfo: 内部ScrollViewのmaxHeightを80%→70%に変更 2026-03-30 02:18:10 +00:00
harukin-expo-dev-env 203edd2014 ActionSheet: スマホのmaxHeightを90%→70%に変更 2026-03-30 02:12:07 +00:00
harukin-expo-dev-env a2836c56d5 ActionSheet: スマホのみmaxHeight(90%)制限、タブレットは無制限
- useSheetMaxHeight() hook追加(短辺<600dpでスマホ判定)
- 全8つのActionSheetにmaxHeight適用
- タブレット/DeXでは制限なし
2026-03-30 02:03:57 +00:00
harukin-expo-dev-env 372c703d67 DeX: Dimensions.getモンキーパッチ + transform scaleで低密度ディスプレイ対応
- Dimensions.get('window')を1/1.3に縮小パッチ
- useWindowDimensions()も自動的に小さい値を返す
- DensityScaleWrapperで1.3倍transform scaleで拡大
- コンポーネントは872x531dpとしてレイアウト→実画面1133x690に拡大
2026-03-29 23:31:26 +00:00
harukin-expo-dev-env eacca5ef22 DeX: DensityScaleWrapperを一旦削除、正常表示に戻す 2026-03-29 17:32:17 +00:00
harukin-expo-dev-env f31bf737b7 DeX: スケール値を1.8→1.3に低減、レイアウト崩れ修正 2026-03-29 17:29:06 +00:00
harukin-expo-dev-env c68e53d53b DeX: DensityScaleWrapperでtransform scaleアプローチに切替、babel plugin wrapStyleForDensityを除去 2026-03-29 17:25:54 +00:00
harukin-expo-dev-env 7a0e7a70f8 test: compensation倍率を大幅増加(2.625/PR, max4.0)で効果確認テスト 2026-03-29 17:16:21 +00:00
harukin-expo-dev-env 91c1876c23 fix: babelプラグインでText/TextInputのstyleをglobal.__scaleTextStyleでラップ
- Text.renderが存在しない(関数コンポーネント)ためモンキーパッチ不可
- babel pluginでビルド時に全Text/TextInputのstyleプロップをラップ
- ランタイムのglobal.__scaleTextStyleがrender毎にPR動的チェック
- PR<2.0: fontSize * min(2.5, 2.0/PR)で拡大
- PR>=2.0: 変更なし(identity)
2026-03-29 17:10:59 +00:00
harukin-expo-dev-env e8002a5572 debug: Text構造の診断Alert追加(render存在確認) 2026-03-29 17:02:05 +00:00
harukin-expo-dev-env 7b082abf13 fix: PixelRatioをrender時に動的チェック(起動時はPhone PR、DeX移動後にPR低下するため) 2026-03-29 16:59:00 +00:00
harukin-expo-dev-env 75030f8a32 fix: Text.renderモンキーパッチで全テキストを低密度ディスプレイで自動拡大
- utils/scaleTextForDensity.ts: PR<2.0のとき全TextのfontSizeを2.0/PR倍(max2.5x)
- App.tsx: 起動時にscaleTextForDensityをインポート
- responsive.ts: fontScaleをidentityに戻す(二重スケーリング防止)
- 178箇所のハードコードfontSizeも個別修正不要で一括対応
2026-03-29 16:56:17 +00:00
harukin-expo-dev-env a97b89bc73 fix: DeX(PR=0.756)実測値に基づく密度補償 fontScale=2.0/PR(max 2.5x)
- DeX実測: Win=1133x690, PR=0.756, FS=0.8, Scr=731x411
- fontScale: 2.0/0.756 = 2.64 → cap 2.5x(テキスト2.5倍拡大)
- moderateScale: 拡大なし(レイアウト寸法保護)
- デバッグAlert/DebugMetrics除去
2026-03-29 16:46:01 +00:00
harukin-expo-dev-env e9b5fdc3e8 debug: Alert.alertでネイティブダイアログ表示(密度非依存の確実な方法) 2026-03-29 16:39:49 +00:00
harukin-expo-dev-env ffdde88378 debug: DebugMetricsをScrollView外に移動(常に画面上部に表示) 2026-03-29 16:35:50 +00:00
harukin-expo-dev-env 224afd95b9 debug: デバッグパネルを赤背景+fontSize40で無条件表示(DeX値確認用) 2026-03-29 16:32:35 +00:00
harukin-expo-dev-env b89aa2aa25 fix: 密度補償を大幅に縮小 - fontScaleのみ1.3x、moderateScaleは拡大なし
- moderateScaleがwidth/height/borderRadiusにも使われておりレイアウト崩壊の原因
- fontScale: 低密度時1.3倍(テキスト可読性のみ改善)
- moderateScale: 常に原寸(レイアウト保護)
- デバッグパネル: 低密度時fontSize固定24
2026-03-29 16:28:10 +00:00
harukin-expo-dev-env 17d22ab6c8 fix: DeX低密度ディスプレイでフォント/アイコンを拡大する密度補償を追加
- PixelRatio < 2.0 のとき densityCompensation = 2.0/PR で拡大(最大2.5倍)
- fontScale/moderateScale が密度補償倍率を適用
- デバッグパネルも密度に応じてfontSizeを自動拡大
- Phone窓モード(PR=2.01)では変化なし、DeX(PR≈1.0)では約2倍に拡大
2026-03-29 16:21:25 +00:00
harukin-expo-dev-env 5b648d73ec fix: DeXで文字の縮小を停止、デバッグパネルのフォントサイズを拡大
- fontScale/moderateScale を恒等関数に変更(低密度ディスプレイでの縮小を停止)
- verticalScale のみレイアウト高さ圧縮に使用
- デバッグパネルのフォントサイズを10→16に拡大(DeXでも読めるように)
- デバッグ情報を簡潔化(Win/Scr/LowDensity表示)
2026-03-29 16:14:34 +00:00
harukin-expo-dev-env 5bbc3d589a debug: DeX環境調査用のメトリクス表示を追加(一時的)
useWindowDimensions, Dimensions(window/screen), PixelRatio, FontScaleの
実際の値をメイン画面に表示してDeXでの挙動を特定する
2026-03-29 16:03:33 +00:00
harukin-expo-dev-env c8f1d6d332 fix: レスポンシブスケーリングを大幅強化 - fontScale 0.4→0.8、moderateScale 0.5→0.8
- responsive.ts: スケーリングファクターを大幅強化(fontScale 0.8, moderateScale 0.8)
- initIcon: タブバーアイコンsize 30→moderateScale(30)
- SimpleDot: ドットサイズ20/14→moderateScale
- StationPagination: 全寸法(28/24/18/22/8/6)をmoderateScale/fontScale化
- CarouselBox: fontSize 20/14→fontScale
- MenuPage: マップオフセット100/80/10/30→verticalScale
- menu: スクロールオフセット/snapToOffsets→verticalScale
2026-03-29 15:52:29 +00:00
harukin-expo-dev-env 82f3a20203 feat: メイン画面のレスポンシブ対応 - Menu/atom/TrainMenuコンポーネントにスケーリング適用
- TitleBar: ヘッダー高さをverticalScaleに
- UsefulBox: アイコン50→moderateScale、フォント16→fontScale
- TicketBox: フォント18→fontScale
- TextBox: minHeight 70→verticalScale
- FixedContentBottom: 全fontSize(20/18)→fontScale、全アイコンsize(50/40)→moderateScale
- JRSTraInfoBox: 全fontSize(30/20/18)、アイコン、maxHeight、ローディングサイズをスケーリング
- SpecialTrainInfoBox: fontSize(30/20)→fontScale
- CarouselTypeChanger: height 40→verticalScale
2026-03-29 15:41:06 +00:00
harukin-expo-dev-env fc88bd52f7 feat: Samsung DeXレスポンシブ対応 - lib/responsive.ts追加、主要コンポーネントにスケーリング適用
- useResponsive()フックを作成(fontScale/verticalScale/moderateScale)
- ベースデザイン: iPhone 14 Pro (393x852)、スケールダウンのみ(最大1.0)
- DynamicHeaderScrollView: ヘッダー高さにverticalScale適用
- EachTrainInfoCore: maxHeight緩和(70→80%)、ハンドラーサイズスケーリング
- StateBox/PositionBox/ScrollStickyContent/HeaderText: fontScale適用
- DataConnectedButton/EachStopList: fontScale+moderateScale適用
- JRSTraInfo/StationDeteilView: ハンドラー+フォントスケーリング
- ReloadButton/MapsButton/NewMenu: ボタンサイズ+フォントスケーリング
- useDeviceOrientationChange: 全Android端末でisLandscape=false
2026-03-29 15:23:28 +00:00
harukin-expo-dev-env 608039e2f9 fix: タブレットの判定ロジックを追加し、サイドパネルレイアウトの使用を制御 2026-03-29 14:27:10 +00:00
harukin-expo-dev-env dd7a9ae139 fix: iPad ActionSheet/isLandscape対応 - isModalをiPad無効化、maxHeight追加、isLandscape常時false 2026-03-29 14:09:19 +00:00
harukin-expo-dev-env e7c4782ec7 fix: iPadでのヘッダー表示問題を修正 (ScrollView→View, width: 100%) 2026-03-29 14:07:07 +00:00
harukin-expo-dev-env 0e8cd1431f fix: FelicaBalanceWidgetの表示をiPhoneのみに制限し、ダーク/ライトモードに対応した色設定を追加 2026-03-29 11:55:12 +00:00
harukin-expo-dev-env be0080389a fix: サウンド設定のJavaScriptインジェクションを遅延させ、列車番号のオーバーライドを追加 2026-03-29 11:42:09 +00:00
harukin-expo-dev-env 5589ca0a43 fix: Unyohub列番の上書き処理を追加し、運用Hub情報の取得を改善 2026-03-29 11:38:18 +00:00
harukin-expo-dev-env 6041c01bd6 fix: サウンド設定とレイアウト設定のナビゲーションを修正し、視認性向上のためのセクションヘッダーを追加 2026-03-28 04:07:46 +00:00
harukin-expo-dev-env 69b8dc9d88 feat: カラーテーマ設定とサウンド設定機能を追加し、外部起動方法をREADMEに記載 2026-03-26 13:55:24 +00:00
harukin-expo-dev-env a4e3e0833d fix: UIの色設定をダークモードに対応させ、視認性を向上 2026-03-26 08:56:31 +00:00
harukin-expo-dev-env cae85fd014 fix: 駅固定通知の出発済み列車フィルタと棒線駅接近判定を修正
- バックグラウンドで出発済み列車が消えないバグを修正
  (LiveActivityForegroundService: scheduledMinutes+delay<nowMinutes で除外)
- parseTimeToMinutes() ヘルパーを Kotlin に追加
- UI側も遅延加算した発車時刻で出発済み判定するよう統一
  (StationDiagramView: getDelayMinutes + dayjs.add)
- between.ts の BetweenStation を使った接近セクション判定を実装
  (棒線駅で列車がセクション手前に居る場合は未到達扱いで保持)
- StationTrainInfo に sectionStation フィールドを追加
  (prevStop/nextStop を廃止し API Pos と直接照合可能な文字列に変更)
- docs/station-lock-dual-impl.md 作成(KotlinとTS間のデュアル実装ガイド)
2026-03-26 03:00:59 +00:00
harukin-expo-dev-env 5ac4d2fc80 Refactor code structure for improved readability and maintainability 2026-03-25 15:09:56 +00:00
harukin-expo-dev-env e1b4635595 Merge commit 'c6346b8b0e18da3067aa6084f9c591198a0e7678' into develop 2026-03-25 14:06:43 +00:00
harukin-expo-dev-env c6346b8b0e feat: implement sound announcement feature for delayed trains with settings 2026-03-25 14:05:59 +00:00
harukin-expo-dev-env 977f918d28 refactor: improve audio source handling for platform compatibility 2026-03-25 04:07:28 +00:00
harukin-expo-dev-env 15c602e4bf feat: implement elesite data source integration and permission handling 2026-03-25 01:56:12 +00:00
harukin-expo-dev-env 89617b53fa feat: add detailed changelog for version 7.0beta with feature enhancements and improvements 2026-03-25 01:44:54 +00:00
harukin-expo-dev-env 8b82e0d3af refactor: remove LiveActivityButton from EachTrainInfoCore component 2026-03-25 01:30:57 +00:00
harukinMBP 0033d0ec82 feat: add SpotSign component for tourist attractions 2026-03-25 10:22:12 +09:00
harukinMBP 8eba461a25 refactor: remove live notification functionality from FixedStation and FixedTrain components 2026-03-25 02:07:43 +09:00
harukinMBP 8121e2d57d fix: TypeScript build errors (143 to 47) 2026-03-25 01:44:29 +09:00
harukin-expo-dev-env d81eb2d129 Merge origin/develop into develop and resolve conflicts 2026-03-24 16:11:08 +00:00
harukin-expo-dev-env d922bc48f1 feat: update iOS build number to 59 in app.json 2026-03-22 17:11:15 +00:00
harukin-expo-dev-env 567833cb93 Merge commit '3d6fc232841fc85a68d17f95b8f91cdc5aad48c2' into develop 2026-03-22 17:10:00 +00:00
harukin-expo-dev-env 3d6fc23284 Merge commit 'd3fb44b71d09158a14423be4594c45e94dc00085' into feature/migration-expo-upgrade 2026-03-22 17:09:51 +00:00
harukin-expo-dev-env d3fb44b71d feat: add station progress tracking and notification updates for train follow feature 2026-03-22 17:09:19 +00:00
harukinMBP e1bdcee08c feat: add Live Activity button for train tracking and update iOS deployment target 2026-03-23 02:07:24 +09:00
harukin-expo-dev-env b6c0113469 feat: add live activity notifications for train tracking and station locking
- Implemented ExpoLiveActivity module for Android to manage live notifications.
- Added foreground service for train tracking and station locking notifications.
- Updated app permissions to include POST_NOTIFICATIONS.
- Enhanced FixedStation and FixedTrain components to support live notifications.
- Introduced new notification builders for train and station activities.
- Updated useCurrentTrain and useNotifications hooks to manage live notification state.
- Added notification channel for live tracking in Android.
2026-03-22 16:15:48 +00:00
harukinMBP ac0435eb8f fix: update activity state handling to use ActivityContent with nil staleDate 2026-03-22 23:09:13 +09:00
harukinMBP 88e469abe6 feat: add automatic Live Activity updates for train position and station lock in TrainDataView and StationDiagramView 2026-03-22 23:03:07 +09:00
harukinMBP 1a9be4a672 feat: add Live Activity support for station locking in StationDiagramView 2026-03-22 23:01:00 +09:00
harukinMBP 9fb722b1c2 feat: add hooks for managing Live Activities for station locking and train following 2026-03-22 22:51:58 +09:00
harukinMBP 25561f2f54 feat: add Live Activities support for train tracking and station locking 2026-03-22 22:49:05 +09:00
harukin-expo-dev-env 1ce9410081 feat: add androidGoogleMapsApiKey to app.json configuration 2026-03-22 13:17:53 +00:00
harukin-expo-dev-env 1ed9627d67 feat: add appleTeamId to iOS configuration in app.json 2026-03-22 11:48:59 +00:00
harukin-expo-dev-env a134086974 feat(widget): add Shortcut, Delay Info, and Felica Balance widgets
- Implemented ShortcutWidget for quick access to app features with customizable shortcuts.
- Added DelayInfoWidget to display train delay information fetched from a remote endpoint.
- Created FelicaBalanceWidget to show the balance of Felica-compatible IC cards.
- Introduced OperationInfoWidget for displaying train operation status.
- Set up shared data handling for Felica snapshots between the main app and widget.
- Configured widget assets and entitlements for proper functionality.
- Updated Info.plist and expo-target.config.js for widget deployment.
2026-03-22 11:45:58 +00:00
harukin-expo-dev-env be6f18f504 feat: FeliCa対応の可用性チェック機能を追加 2026-03-22 10:13:36 +00:00
harukinMBP 4ebeaa1be5 Update app.json build number to 58 and refine NFC error handling in ExpoFelicaReaderModule 2026-03-22 18:56:13 +09:00
harukinMBP 3deea646f8 Implement NFC scanning functionality in ExpoFelicaReader module 2026-03-21 22:04:55 +09:00
harukinMBP a1f06641ba Fix .gitignore: re-include modules/**/ios/ for EAS build, remove debug code 2026-03-21 20:13:34 +09:00
harukinMBP 4b0ed6a503 Debug: patch resolveCommand.js and apple.js with logging, add pod fallback 2026-03-21 20:03:40 +09:00
harukinMBP 1298cc2bf7 Enhanced autolinking debug: use external JS file 2026-03-21 19:46:25 +09:00
harukinMBP 21e3c46ae4 Add Podfile debug plugin to trace autolinking resolve 2026-03-21 19:33:22 +09:00
harukinMBP 1094911f2c Fix ExpoFelicaReader module: restore full definition, update diagnostics 2026-03-21 17:54:54 +09:00
harukinMBP c474f0586f Add expo-felica-reader to dependencies for EAS build autolinking 2026-03-21 17:54:39 +09:00
harukin-expo-dev-env d9a3c7b245 Refactor code structure for improved readability and maintainability 2026-03-20 07:14:58 +00:00
harukin-expo-dev-envandCopilot dfd1e3a046 fix: ダークモード - 設定リスト・お気に入り並び替え・SNSリストの表示修正
- SettingTopPage: SettingList の containerStyle を直接設定して背景色を適用
- FavoiliteSettingsItem: 駅名テキストと並び替えアイコンに color: colors.text を追加
- SocialMenu: SNS一覧の ListItem に containerStyle と ListItem.Title の色を追加

Co-authored-by: Copilot <[email protected]>
2026-03-19 08:46:14 +00:00
harukin-expo-dev-envandCopilot b7a8ea0c8c feat: iPad サポートを有効化
app.json の supportsTablet を true に変更

Co-authored-by: Copilot <[email protected]>
2026-03-18 08:44:36 +00:00
harukin-expo-dev-envandCopilot 58257b0220 perf: dev client で expo-updates をスキップ
__DEV__ 時は checkForUpdateAsync() が不要なため早期リターン

Co-authored-by: Copilot <[email protected]>
2026-03-18 08:38:10 +00:00
harukin-expo-dev-envandCopilot 153d601730 perf: metro transformer に experimentalImportSupport を復元
lazyImports: true と組み合わせて動作するオプション。
resolver への誤設定は除外し transformer のみに適用。

Co-authored-by: Copilot <[email protected]>
2026-03-18 07:42:55 +00:00
93 changed files with 16108 additions and 3175 deletions
+2 -1
View File
@@ -59,4 +59,5 @@ ios/
!modules/**/ios/ !modules/**/ios/
*.ipa *.ipa
*.apk *.apk
*.aab *.aab
.env.local
+7
View File
@@ -0,0 +1,7 @@
{
"mcpServers": {
"Sentry": {
"url": "https://mcp.sentry.dev/mcp/xprocess-m5/jr-shikoku-unofficial-apps"
}
}
}
+117 -12
View File
@@ -5,6 +5,7 @@ import {
useRealWindowDimensions, useRealWindowDimensions,
} from "./utils/dexDimensionOverride"; } from "./utils/dexDimensionOverride";
import { Linking, Platform, UIManager, View } from "react-native"; import { Linking, Platform, UIManager, View } from "react-native";
import { enableFreeze, enableScreens } from "react-native-screens";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import "./utils/disableFontScaling"; // グローバルなフォントスケーリング無効化 import "./utils/disableFontScaling"; // グローバルなフォントスケーリング無効化
import { AppContainer } from "./Apps"; import { AppContainer } from "./Apps";
@@ -25,26 +26,107 @@ import { buildProvidersTree } from "./lib/providerTreeProvider";
import { StationListProvider } from "./stateBox/useStationList"; import { StationListProvider } from "./stateBox/useStationList";
import { NotificationProvider } from "./stateBox/useNotifications"; import { NotificationProvider } from "./stateBox/useNotifications";
import { UserPositionProvider } from "./stateBox/useUserPosition"; import { UserPositionProvider } from "./stateBox/useUserPosition";
import { rootNavigationRef, stackAwareNavigate } from "./lib/rootNavigation"; import {
markStartupExplicitTarget,
resolveStartupNavigationSource,
rootNavigationRef,
stackAwareNavigate,
} from "./lib/rootNavigation";
import { AppThemeProvider } from "./lib/theme"; import { AppThemeProvider } from "./lib/theme";
import StatusbarDetect from "./StatusbarDetect"; import StatusbarDetect from "./StatusbarDetect";
import * as Sentry from '@sentry/react-native';
import {
startAppLifecycleCrashSentinel,
stopAppLifecycleCrashSentinel,
} from "./lib/observability/appLifecycleCrashSentinel";
import { migrateLegacyVoicepeakSettings } from "./lib/migrateLegacyVoicepeakSettings";
Sentry.init({
dsn: 'https://1090312e4cf501f5a455d523eff2d538@o4511646874664960.ingest.us.sentry.io/4511646880432128',
// Adds more context data to events (IP address, cookies, user, etc.)
// For more information, visit: https://docs.sentry.io/platforms/react-native/data-management/data-collected/
sendDefaultPii: true,
// Enable Logs
enableLogs: true,
// Configure Session Replay
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1,
integrations: [Sentry.mobileReplayIntegration(), Sentry.feedbackIntegration()],
tracesSampleRate: __DEV__ ? 1.0 : 0.05,
beforeSend(event) {
const dataFetch = event.contexts?.data_fetch as any;
if (dataFetch?.responseHead && typeof dataFetch.responseHead === "string") {
dataFetch.responseHead = dataFetch.responseHead.slice(0, 300);
}
return event;
},
// uncomment the line below to enable Spotlight (https://spotlightjs.com)
// spotlight: __DEV__,
});
LogBox.ignoreLogs([ LogBox.ignoreLogs([
"ViewPropTypes will be removed", "ViewPropTypes will be removed",
"ColorPropType will be removed", "ColorPropType will be removed",
]); ]);
const nativeScreensDisabled =
Platform.OS === "ios" || Platform.OS === "android";
if (nativeScreensDisabled) {
enableFreeze(false);
enableScreens(false);
}
if (Platform.OS === "android") { if (Platform.OS === "android") {
if (UIManager.setLayoutAnimationEnabledExperimental) { if (UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true); UIManager.setLayoutAnimationEnabledExperimental(true);
} }
} }
export default function App() { export default Sentry.wrap(function App() {
useEffect(() => {
const nativeScreensMode = nativeScreensDisabled
? "screens-disabled"
: "default";
const rootTabSceneMode =
Platform.OS === "android" ? "opacity-fixed-z-v1" : "default";
Sentry.setTag("native_screens_mode", nativeScreensMode);
Sentry.setTag("root_tab_scene_mode", rootTabSceneMode);
Sentry.setContext("runtime_navigation_flags", {
platform: Platform.OS,
nativeScreensMode,
rootTabSceneMode,
});
Sentry.addBreadcrumb({
category: "runtime.flags",
level: "info",
message: "runtime navigation flags applied",
data: {
platform: Platform.OS,
nativeScreensMode,
rootTabSceneMode,
},
});
void startAppLifecycleCrashSentinel({ nativeScreensMode });
return () => {
stopAppLifecycleCrashSentinel();
};
}, []);
useEffect(() => { useEffect(() => {
UpdateAsync(); UpdateAsync();
}, []); }, []);
useEffect(() => {
void migrateLegacyVoicepeakSettings();
}, []);
useEffect(() => { useEffect(() => {
const openFelicaPage = (retryCount = 0) => { const openFelicaPage = (retryCount = 0) => {
if (!rootNavigationRef.isReady()) { if (!rootNavigationRef.isReady()) {
@@ -78,46 +160,69 @@ export default function App() {
const routeFromUrl = (url: string, retryCount = 0) => { const routeFromUrl = (url: string, retryCount = 0) => {
const normalized = (url || "").toLowerCase(); const normalized = (url || "").toLowerCase();
if (!normalized) return; if (!normalized) return false;
if ( if (
normalized.includes("felicahistorypage") || normalized.includes("felicahistorypage") ||
normalized.includes("open/felica") normalized.includes("open/felica")
) { ) {
markStartupExplicitTarget();
navigateWhenReady(() => openFelicaPage(), url, retryCount); navigateWhenReady(() => openFelicaPage(), url, retryCount);
} else if (normalized.includes("open/traininfo")) { return true;
}
if (normalized.includes("open/traininfo")) {
markStartupExplicitTarget();
navigateWhenReady(() => { navigateWhenReady(() => {
stackAwareNavigate("topMenu", { screen: "menu" }); stackAwareNavigate("topMenu", { screen: "menu" });
setTimeout(() => { setTimeout(() => {
SheetManager.show("JRSTraInfo"); SheetManager.show("JRSTraInfo");
}, 450); }, 450);
}, url, retryCount); }, url, retryCount);
} else if (normalized.includes("open/operation")) { return true;
}
if (normalized.includes("open/operation")) {
markStartupExplicitTarget();
navigateWhenReady(() => { navigateWhenReady(() => {
stackAwareNavigate("information"); stackAwareNavigate("information");
}, url, retryCount); }, url, retryCount);
} else if (normalized.includes("open/settings")) { return true;
}
if (normalized.includes("open/settings")) {
markStartupExplicitTarget();
navigateWhenReady(() => { navigateWhenReady(() => {
stackAwareNavigate("topMenu", { stackAwareNavigate("topMenu", {
screen: "setting", screen: "setting",
}); });
}, url, retryCount); }, url, retryCount);
} else if (normalized.includes("open/topmenu")) { return true;
}
if (normalized.includes("open/topmenu")) {
markStartupExplicitTarget();
navigateWhenReady(() => { navigateWhenReady(() => {
stackAwareNavigate("topMenu", { stackAwareNavigate("topMenu", {
screen: "menu", screen: "menu",
}); });
}, url, retryCount); }, url, retryCount);
} else if (normalized.includes("positions/apps")) { return true;
}
if (normalized.includes("positions/apps")) {
markStartupExplicitTarget();
navigateWhenReady(() => { navigateWhenReady(() => {
stackAwareNavigate("positions"); stackAwareNavigate("positions");
}, url, retryCount); }, url, retryCount);
return true;
} }
return false;
}; };
Linking.getInitialURL().then((url) => { Linking.getInitialURL()
if (url) routeFromUrl(url); .then((url) => {
}); if (url) routeFromUrl(url);
})
.finally(() => {
resolveStartupNavigationSource();
});
const sub = Linking.addEventListener("url", ({ url }) => { const sub = Linking.addEventListener("url", ({ url }) => {
routeFromUrl(url); routeFromUrl(url);
@@ -157,7 +262,7 @@ export default function App() {
</DeviceOrientationChangeProvider> </DeviceOrientationChangeProvider>
</AppThemeProvider> </AppThemeProvider>
); );
} });
/** /**
* 低密度ディスプレイ(DeX等)で全体を transform scale で拡大。 * 低密度ディスプレイ(DeX等)で全体を transform scale で拡大。
+572 -13
View File
@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { NavigationContainer, DarkTheme, DefaultTheme } from "@react-navigation/native"; import { NavigationContainer, DarkTheme, DefaultTheme, type EventArg } from "@react-navigation/native";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { Animated, Platform, ActivityIndicator, View, StyleSheet, StatusBar, useWindowDimensions } from "react-native"; import { Animated, Platform, ActivityIndicator, View, StyleSheet, StatusBar, useWindowDimensions, InteractionManager } from "react-native";
import { useNavigationState } from "@react-navigation/native"; import { useNavigationState } from "@react-navigation/native";
import { useFonts } from "expo-font"; import { useFonts } from "expo-font";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
@@ -14,9 +14,21 @@ import { useTrainMenu } from "./stateBox/useTrainMenu";
import lineColorList from "./assets/originData/lineColorList"; import lineColorList from "./assets/originData/lineColorList";
import { stationIDPair } from "./lib/getStationList"; import { stationIDPair } from "./lib/getStationList";
import "./components/ActionSheetComponents/sheets"; import "./components/ActionSheetComponents/sheets";
import { rootNavigationRef } from "./lib/rootNavigation"; import { lastObservedRootRouteRef, positionsLifecycleRef, rootNavigationRef } from "./lib/rootNavigation";
import {
startupExplicitTargetRef,
waitForStartupNavigationResolution,
} from "./lib/rootNavigation";
import { fixedColors } from "./lib/theme/colors"; import { fixedColors } from "./lib/theme/colors";
import { useThemeColors } from "./lib/theme"; import { useThemeColors } from "./lib/theme";
import * as Sentry from "@sentry/react-native";
import WebView from "react-native-webview";
import { AS } from "./storageControl";
import { STORAGE_KEYS } from "./constants/storage";
import {
recordAppLifecycleRootNavigation,
setAppLifecycleWebViewActive,
} from "./lib/observability/appLifecycleCrashSentinel";
type RootTabParamList = { type RootTabParamList = {
positions: undefined; positions: undefined;
@@ -35,12 +47,219 @@ type TabProps = {
const Tab = createBottomTabNavigator<RootTabParamList>(); const Tab = createBottomTabNavigator<RootTabParamList>();
const HiddenStartupPreloadWebViews = ({
shouldPreloadPositions,
shouldPreloadInformation,
}: {
shouldPreloadPositions: boolean;
shouldPreloadInformation: boolean;
}) => {
const [positionsLoaded, setPositionsLoaded] = React.useState(false);
const [informationLoaded, setInformationLoaded] = React.useState(false);
React.useEffect(() => {
Sentry.setContext("startup_hidden_preload", {
shouldPreloadPositions,
shouldPreloadInformation,
positionsLoaded,
informationLoaded,
});
}, [informationLoaded, positionsLoaded, shouldPreloadInformation, shouldPreloadPositions]);
const hiddenStyle = React.useMemo(() => ({
position: "absolute" as const,
width: 1,
height: 1,
opacity: 0,
left: -1000,
top: -1000,
pointerEvents: "none" as const,
}), []);
const showPositions = shouldPreloadPositions && !positionsLoaded;
const showInformation = shouldPreloadInformation && !informationLoaded;
if (!showPositions && !showInformation) return null;
return (
<View pointerEvents="none" style={hiddenStyle}>
{showPositions ? (
<WebView
source={{ uri: "https://train.jr-shikoku.co.jp/" }}
originWhitelist={["https://train.jr-shikoku.co.jp"]}
javaScriptEnabled
setSupportMultipleWindows={false}
onLoadStart={() => {
setAppLifecycleWebViewActive("startup_hidden_positions", true);
Sentry.addBreadcrumb({
category: "startup.preload",
level: "info",
message: "positions hidden preload loadStart",
});
}}
onLoadEnd={() => {
setAppLifecycleWebViewActive("startup_hidden_positions", false);
Sentry.addBreadcrumb({
category: "startup.preload",
level: "info",
message: "positions hidden preload loadEnd",
});
setPositionsLoaded(true);
}}
onError={() => {
setAppLifecycleWebViewActive("startup_hidden_positions", false);
Sentry.addBreadcrumb({
category: "startup.preload",
level: "error",
message: "positions hidden preload error",
});
}}
/>
) : null}
{showInformation ? (
<WebView
source={{ uri: "https://www.jr-shikoku.co.jp/info/" }}
originWhitelist={["https://www.jr-shikoku.co.jp"]}
javaScriptEnabled
setSupportMultipleWindows={false}
onLoadStart={() => {
setAppLifecycleWebViewActive("startup_hidden_operation", true);
Sentry.addBreadcrumb({
category: "startup.preload",
level: "info",
message: "operation hidden preload loadStart",
});
}}
onLoadEnd={() => {
setAppLifecycleWebViewActive("startup_hidden_operation", false);
Sentry.addBreadcrumb({
category: "startup.preload",
level: "info",
message: "operation hidden preload loadEnd",
});
setInformationLoaded(true);
}}
onError={() => {
setAppLifecycleWebViewActive("startup_hidden_operation", false);
Sentry.addBreadcrumb({
category: "startup.preload",
level: "error",
message: "operation hidden preload error",
});
}}
/>
) : null}
</View>
);
};
const PositionsRootGateContext = React.createContext({
shouldActivate: false,
isRootFocused: false,
});
const InformationRootGateContext = React.createContext({
shouldActivate: false,
isRootFocused: false,
});
const DeferredPositionsRoot = ({ shouldActivate, isRootFocused }: { shouldActivate: boolean; isRootFocused: boolean }) => {
const [hasActivated, setHasActivated] = React.useState(false);
React.useEffect(() => {
Sentry.setContext("positions_root_gate", {
focused: isRootFocused,
activated: hasActivated,
shouldActivate,
});
}, [hasActivated, isRootFocused, shouldActivate]);
React.useEffect(() => {
if (!shouldActivate || hasActivated) return;
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions root activation",
data: {
reason: "first_root_visit",
},
});
setHasActivated(true);
}, [hasActivated, shouldActivate]);
if (!hasActivated) {
return <View style={{ flex: 1 }} />;
}
return <Top />;
};
const PositionsTabScreen = React.memo(() => {
const { shouldActivate, isRootFocused } = React.useContext(PositionsRootGateContext);
return (
<DeferredPositionsRoot
shouldActivate={shouldActivate}
isRootFocused={isRootFocused}
/>
);
});
PositionsTabScreen.displayName = "PositionsTabScreen";
const DeferredInformationRoot = ({ shouldActivate, isRootFocused }: { shouldActivate: boolean; isRootFocused: boolean }) => {
const [hasActivated, setHasActivated] = React.useState(false);
React.useEffect(() => {
Sentry.setContext("operation_root_gate", {
focused: isRootFocused,
activated: hasActivated,
shouldActivate,
});
}, [hasActivated, isRootFocused, shouldActivate]);
React.useEffect(() => {
if (!shouldActivate || hasActivated) return;
Sentry.addBreadcrumb({
category: "operation.root",
level: "info",
message: "operation root activation",
data: {
reason: isRootFocused ? "first_focus" : "background_prewarm",
},
});
setHasActivated(true);
}, [hasActivated, isRootFocused, shouldActivate]);
if (!hasActivated) {
return <View style={{ flex: 1 }} />;
}
return <TNDView />;
};
const InformationTabScreen = React.memo(() => {
const { shouldActivate, isRootFocused } = React.useContext(InformationRootGateContext);
return (
<DeferredInformationRoot
shouldActivate={shouldActivate}
isRootFocused={isRootFocused}
/>
);
});
InformationTabScreen.displayName = "InformationTabScreen";
export function AppContainer() { export function AppContainer() {
const { areaInfo, areaIconBadgeText, isInfo } = useAreaInfo(); const { areaInfo, areaIconBadgeText, isInfo } = useAreaInfo();
const { selectedLine } = useTrainMenu(); const { selectedLine } = useTrainMenu();
const { width, height } = useWindowDimensions(); const { width, height } = useWindowDimensions();
const operationScreenKey = width > height ? "operation-landscape" : "operation-portrait";
const [isExtraWindowOpen, setIsExtraWindowOpen] = React.useState(false); const [isExtraWindowOpen, setIsExtraWindowOpen] = React.useState(false);
const [startupInitialRoute, setStartupInitialRoute] = React.useState<keyof RootTabParamList | null>(null);
const [hasVisitedPositions, setHasVisitedPositions] = React.useState(false);
const [hasVisitedInformation, setHasVisitedInformation] = React.useState(false);
const [currentRootRoute, setCurrentRootRoute] = React.useState<keyof RootTabParamList | null>(null);
const [navigationReady, setNavigationReady] = React.useState(false);
const startupPrewarmAttemptedRef = React.useRef(false);
const informationStartupPrewarmAttemptedRef = React.useRef(false);
// フェードアニメーション用 (0=通常, 1=追加ウィンドウ青) // フェードアニメーション用 (0=通常, 1=追加ウィンドウ青)
const fadeAnim = React.useRef(new Animated.Value(0)).current; const fadeAnim = React.useRef(new Animated.Value(0)).current;
@@ -64,7 +283,47 @@ export function AppContainer() {
return `#${[r, g, b].map((v) => Math.min(255, v).toString(16).padStart(2, "0")).join("")}`; return `#${[r, g, b].map((v) => Math.min(255, v).toString(16).padStart(2, "0")).join("")}`;
}; };
const { isDark } = useThemeColors(); const { isDark } = useThemeColors();
const lastRootNavStateRef = React.useRef("");
const lineColorDark = lineColor ? darkenHex(lineColor, 0.78) : null; const lineColorDark = lineColor ? darkenHex(lineColor, 0.78) : null;
React.useEffect(() => {
let cancelled = false;
void (async () => {
await waitForStartupNavigationResolution();
if (cancelled) return;
let nextInitialRoute: keyof RootTabParamList = "topMenu";
if (!startupExplicitTargetRef.current) {
try {
const startPage = await AS.getItem(STORAGE_KEYS.START_PAGE);
if (startPage === "true") {
nextInitialRoute = "positions";
}
} catch {
// Keep the default route when the setting is unset or unreadable.
}
}
if (cancelled) return;
setStartupInitialRoute(nextInitialRoute);
setHasVisitedPositions(nextInitialRoute === "positions");
Sentry.addBreadcrumb({
category: "nav.root",
level: "info",
message: "startup initial root route resolved",
data: {
startupExplicitTarget: startupExplicitTargetRef.current,
initialRoute: nextInitialRoute,
},
});
})();
return () => {
cancelled = true;
};
}, []);
const linking = { const linking = {
prefixes: ["jrshikoku://"], prefixes: ["jrshikoku://"],
config: { config: {
@@ -101,17 +360,152 @@ export function AppContainer() {
tabBarLabel: label, tabBarLabel: label,
headerShown: false, headerShown: false,
gestureEnabled: true, gestureEnabled: true,
unmountOnBlur: false,
tabBarIcon: initIcon(icon as any, iconFamily, tabBarBadge, isInfo), tabBarIcon: initIcon(icon as any, iconFamily, tabBarBadge, isInfo),
}, },
}); });
const applyRootNavigationState = React.useCallback((state: any, source: "ready" | "change") => {
const activeRoute = state?.routes?.[state?.index ?? 0];
const hasExtra = (activeRoute?.state?.index ?? 0) > 0;
const nestedState = activeRoute?.state;
const nestedRoute = nestedState?.routes?.[nestedState.index ?? 0]?.name ?? null;
const signature = JSON.stringify({
rootIndex: state?.index ?? 0,
rootRoute: activeRoute?.name ?? null,
nestedIndex: nestedState?.index ?? 0,
nestedRoute,
hasExtra,
});
if (lastRootNavStateRef.current !== signature) {
lastRootNavStateRef.current = signature;
Sentry.addBreadcrumb({
category: "nav.root",
level: "info",
message: source === "ready" ? "root state initialized" : "root state change",
data: {
rootIndex: state?.index ?? 0,
rootRoute: activeRoute?.name ?? null,
nestedIndex: nestedState?.index ?? 0,
nestedRoute,
hasExtra,
source,
},
});
}
const rootRouteName = (activeRoute?.name ?? null) as keyof RootTabParamList | null;
if (rootRouteName) {
lastObservedRootRouteRef.current = rootRouteName;
}
recordAppLifecycleRootNavigation({
rootIndex: state?.index ?? 0,
rootRoute: rootRouteName,
nestedIndex: nestedState?.index ?? 0,
nestedRoute,
source,
});
Sentry.setTag("root_tab", rootRouteName ?? "unknown");
Sentry.setContext("root_navigation", {
rootIndex: state?.index ?? 0,
rootRoute: rootRouteName,
nestedIndex: nestedState?.index ?? 0,
nestedRoute,
hasExtra,
source,
operationOrientation: width > height ? "landscape" : "portrait",
hasVisitedPositions,
hasVisitedInformation,
});
setCurrentRootRoute(rootRouteName);
setIsExtraWindowOpen(hasExtra);
}, [hasVisitedInformation, hasVisitedPositions, width, height]);
const [fontLoaded, error] = useFonts({ const [fontLoaded, error] = useFonts({
"JR-Nishi": require("./assets/fonts/jr-nishi.otf"), "JR-Nishi": require("./assets/fonts/jr-nishi.otf"),
"JR-WEST-PLUS": require("./assets/fonts/JR-WEST-PLUS.otf"),
Zou: require("./assets/fonts/DelaGothicOne-Regular.ttf"), Zou: require("./assets/fonts/DelaGothicOne-Regular.ttf"),
"JNR-font": require("./assets/fonts/JNRfont_pict.ttf"), "JNR-font": require("./assets/fonts/JNRfont_pict.ttf"),
"DiaPro": require("./assets/fonts/DiaPro-Regular.otf"), "DiaPro": require("./assets/fonts/DiaPro-Regular.otf"),
}); });
if (!fontLoaded) { React.useEffect(() => {
if (!navigationReady || !fontLoaded) return;
if (startupPrewarmAttemptedRef.current) return;
if (hasVisitedPositions) {
startupPrewarmAttemptedRef.current = true;
return;
}
if (currentRootRoute === null || currentRootRoute === "positions") return;
startupPrewarmAttemptedRef.current = true;
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions startup prewarm scheduled",
data: {
currentRootRoute,
},
});
let cancelled = false;
const timer = setTimeout(() => {
if (cancelled || hasVisitedPositions) return;
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions startup prewarm activated",
data: {
currentRootRoute,
},
});
setHasVisitedPositions(true);
}, 500);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [currentRootRoute, fontLoaded, hasVisitedPositions, navigationReady]);
React.useEffect(() => {
if (!navigationReady || !fontLoaded) return;
if (informationStartupPrewarmAttemptedRef.current) return;
if (hasVisitedInformation) {
informationStartupPrewarmAttemptedRef.current = true;
return;
}
if (currentRootRoute === null || currentRootRoute === "information") return;
informationStartupPrewarmAttemptedRef.current = true;
Sentry.addBreadcrumb({
category: "operation.root",
level: "info",
message: "operation startup prewarm scheduled",
data: {
currentRootRoute,
},
});
let cancelled = false;
const timer = setTimeout(() => {
if (cancelled || hasVisitedInformation) return;
Sentry.addBreadcrumb({
category: "operation.root",
level: "info",
message: "operation startup prewarm activated",
data: {
currentRootRoute,
},
});
setHasVisitedInformation(true);
}, 500);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [currentRootRoute, fontLoaded, hasVisitedInformation, navigationReady]);
if (!fontLoaded || !startupInitialRoute) {
return ( return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}> <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator size="large" /> <ActivityIndicator size="large" />
@@ -123,14 +517,123 @@ export function AppContainer() {
ref={rootNavigationRef} ref={rootNavigationRef}
linking={linking} linking={linking}
theme={isDark ? DarkTheme : DefaultTheme} theme={isDark ? DarkTheme : DefaultTheme}
onReady={() => {
Sentry.addBreadcrumb({
category: "nav.root",
level: "info",
message: "navigation ready",
});
applyRootNavigationState(rootNavigationRef.getRootState?.() ?? rootNavigationRef.getState?.(), "ready");
setNavigationReady(true);
}}
onStateChange={(state) => { onStateChange={(state) => {
const activeRoute = state?.routes?.[state?.index ?? 0]; applyRootNavigationState(state, "change");
const hasExtra = (activeRoute?.state?.index ?? 0) > 0;
setIsExtraWindowOpen(hasExtra);
}} }}
> >
<PositionsRootGateContext.Provider
value={{
shouldActivate: hasVisitedPositions,
isRootFocused: currentRootRoute === "positions",
}}
>
<InformationRootGateContext.Provider
value={{
shouldActivate: hasVisitedInformation,
isRootFocused: currentRootRoute === "information",
}}
>
<Tab.Navigator <Tab.Navigator
initialRouteName="topMenu" id="rootTabs"
detachInactiveScreens={false}
initialRouteName={startupInitialRoute}
screenListeners={({ route }) => ({
tabPress: (event: EventArg<"tabPress", true>) => {
const rootState = rootNavigationRef.getState();
const activeRootRouteName =
(rootState?.routes?.[rootState.index ?? 0]?.name as keyof RootTabParamList | undefined) ??
currentRootRoute ??
null;
const leavingUnstablePositions =
route.name !== "positions" &&
positionsLifecycleRef.current.isUnstable;
const blockingUnstableExit =
route.name !== "positions" &&
positionsLifecycleRef.current.blockTabExit;
Sentry.addBreadcrumb({
category: "nav.tab",
level: "info",
message: blockingUnstableExit
? "root tabPress blocked while positions unstable"
: leavingUnstablePositions
? "root tabPress intercepted for unstable positions"
: "root tabPress",
data: {
route: route.name,
currentRootRoute,
activeRootRouteName,
unstablePositions: positionsLifecycleRef.current.isUnstable,
blockTabExit: positionsLifecycleRef.current.blockTabExit,
},
});
if (blockingUnstableExit) {
event.preventDefault();
positionsLifecycleRef.current.resetBeforeLeave?.();
requestAnimationFrame(() => {
setTimeout(() => {
rootNavigationRef.navigate(route.name as never);
}, 0);
});
return;
}
if (leavingUnstablePositions) {
event.preventDefault();
positionsLifecycleRef.current.resetBeforeLeave?.();
requestAnimationFrame(() => {
setTimeout(() => {
rootNavigationRef.navigate(route.name as never);
}, 0);
});
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions root exit reset and continued while session unstable",
data: {
route: route.name,
},
});
}
},
focus: () => {
Sentry.addBreadcrumb({
category: "nav.tab",
level: "info",
message: "root tab focus",
data: { route: route.name },
});
},
blur: () => {
Sentry.addBreadcrumb({
category: "nav.tab",
level: "info",
message: "root tab blur",
data: { route: route.name },
});
},
state: (event) => {
Sentry.addBreadcrumb({
category: "nav.tab",
level: "info",
message: "root tab state",
data: {
route: route.name,
stateType: event.type,
},
});
},
})}
screenOptions={({ route }) => { screenOptions={({ route }) => {
const showGradient = route.name === "positions" && !!lineColor && !!lineColorDark; const showGradient = route.name === "positions" && !!lineColor && !!lineColorDark;
const defaultBg = isDark ? "#1c1c1e" : "white"; const defaultBg = isDark ? "#1c1c1e" : "white";
@@ -138,6 +641,7 @@ export function AppContainer() {
const defaultInactive = isDark ? "#8e8e93" : "#8e8e93"; const defaultInactive = isDark ? "#8e8e93" : "#8e8e93";
return { return {
lazy: false, lazy: false,
freezeOnBlur: false,
sceneContainerStyle: { backgroundColor: defaultBg }, sceneContainerStyle: { backgroundColor: defaultBg },
tabBarActiveTintColor: (showGradient || isExtraWindowOpen) ? "white" : defaultActive, tabBarActiveTintColor: (showGradient || isExtraWindowOpen) ? "white" : defaultActive,
tabBarInactiveTintColor: (showGradient || isExtraWindowOpen) ? "rgba(255,255,255,0.75)" : defaultInactive, tabBarInactiveTintColor: (showGradient || isExtraWindowOpen) ? "rgba(255,255,255,0.75)" : defaultInactive,
@@ -171,7 +675,32 @@ export function AppContainer() {
> >
<Tab.Screen <Tab.Screen
{...getTabProps("positions", "走行位置", "bar-chart", "AntDesign")} {...getTabProps("positions", "走行位置", "bar-chart", "AntDesign")}
component={Top} component={PositionsTabScreen}
listeners={{
tabPress: () => {
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions root tabPress activation",
});
setHasVisitedPositions(true);
},
focus: () => {
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions root focus activation",
});
setHasVisitedPositions(true);
},
blur: () => {
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions root blur preserved",
});
},
}}
/> />
<Tab.Screen <Tab.Screen
{...getTabProps("topMenu", "トップメニュー", "radio", "Ionicons")} {...getTabProps("topMenu", "トップメニュー", "radio", "Ionicons")}
@@ -187,10 +716,40 @@ export function AppContainer() {
areaInfo ? areaIconBadgeText : undefined, areaInfo ? areaIconBadgeText : undefined,
isInfo isInfo
)} )}
> component={InformationTabScreen}
{() => <TNDView key={operationScreenKey} />} listeners={{
</Tab.Screen> tabPress: () => {
Sentry.addBreadcrumb({
category: "operation.root",
level: "info",
message: "operation root tabPress activation",
});
setHasVisitedInformation(true);
},
focus: () => {
Sentry.addBreadcrumb({
category: "operation.root",
level: "info",
message: "operation root focus activation",
});
setHasVisitedInformation(true);
},
blur: () => {
Sentry.addBreadcrumb({
category: "operation.root",
level: "info",
message: "operation root blur preserved",
});
},
}}
/>
</Tab.Navigator> </Tab.Navigator>
</InformationRootGateContext.Provider>
</PositionsRootGateContext.Provider>
<HiddenStartupPreloadWebViews
shouldPreloadPositions={navigationReady && fontLoaded && currentRootRoute === "topMenu" && !hasVisitedPositions}
shouldPreloadInformation={navigationReady && fontLoaded && currentRootRoute === "topMenu" && !hasVisitedInformation}
/>
</NavigationContainer> </NavigationContainer>
); );
} }
+254 -49
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Alert, ActivityIndicator, AppState, AppStateStatus, BackHandler, StyleSheet, Text, TouchableOpacity, View } from "react-native"; import { Alert, ActivityIndicator, BackHandler, StyleSheet, Text, TouchableOpacity, View } from "react-native";
import * as FileSystem from "expo-file-system/legacy"; import * as FileSystem from "expo-file-system/legacy";
import * as Sharing from "expo-sharing"; import * as Sharing from "expo-sharing";
import { WebView } from "react-native-webview"; import { WebView } from "react-native-webview";
@@ -9,21 +9,124 @@ import { useFocusEffect, useNavigation } from "@react-navigation/native";
import { useThemeColors } from "@/lib/theme"; import { useThemeColors } from "@/lib/theme";
import { AS } from "./storageControl"; import { AS } from "./storageControl";
import { STORAGE_KEYS } from "@/constants"; import { STORAGE_KEYS } from "@/constants";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { import {
DEFAULT_JR_DATA_SYSTEM_ENV, DEFAULT_JR_DATA_SYSTEM_ENV,
normalizeJrDataSystemEnvironment, normalizeJrDataSystemEnvironment,
rewriteJrDataSystemUrl, rewriteJrDataSystemUrl,
} from "@/lib/jrDataSystemEnvironment"; } from "@/lib/jrDataSystemEnvironment";
const RECORDING_DOWNLOAD_BRIDGE_SCRIPT = `
(() => {
if (window.__JRS_RECORDING_DOWNLOAD_BRIDGE__) return true;
window.__JRS_RECORDING_DOWNLOAD_BRIDGE__ = true;
const blobUrls = new Map();
const post = (payload) => {
window.ReactNativeWebView?.postMessage(JSON.stringify(payload));
};
const isLikelyRecordingDownload = (url, anchor) => {
if (!url) return false;
const lowerUrl = String(url).toLowerCase();
const downloadName = anchor?.getAttribute?.('download') || '';
const lowerName = String(downloadName).toLowerCase();
return Boolean(downloadName)
|| lowerUrl.includes('recording')
|| lowerUrl.includes('recordings')
|| lowerUrl.includes('download')
|| lowerUrl.includes('export')
|| lowerUrl.includes('json')
|| lowerName.includes('recording')
|| lowerName.endsWith('.json');
};
const readUrlAsText = async (url) => {
if (blobUrls.has(url)) {
return blobUrls.get(url).text();
}
const response = await fetch(url, { credentials: 'include' });
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.text();
};
const importFromUrl = async (url) => {
try {
const text = await readUrlAsText(url);
post({ type: 'importRecordingDownload', text, sourceUrl: url });
} catch (error) {
post({
type: 'importRecordingDownloadError',
message: error?.message || String(error),
sourceUrl: url,
});
}
};
const findAnchor = (target) => {
let node = target;
while (node && node !== document) {
if (node.tagName === 'A' && node.href) return node;
node = node.parentNode;
}
return null;
};
if (window.URL?.createObjectURL) {
const originalCreateObjectURL = window.URL.createObjectURL.bind(window.URL);
window.URL.createObjectURL = (object) => {
const url = originalCreateObjectURL(object);
if (object instanceof Blob) blobUrls.set(url, object);
return url;
};
}
document.addEventListener('click', (event) => {
const anchor = findAnchor(event.target);
if (!anchor || !isLikelyRecordingDownload(anchor.href, anchor)) return;
event.preventDefault();
event.stopPropagation();
importFromUrl(anchor.href);
}, true);
if (window.HTMLAnchorElement?.prototype?.click) {
const originalClick = window.HTMLAnchorElement.prototype.click;
window.HTMLAnchorElement.prototype.click = function patchedClick() {
if (isLikelyRecordingDownload(this.href, this)) {
importFromUrl(this.href);
return;
}
return originalClick.apply(this, arguments);
};
}
return true;
})();
true;
`;
const isLikelyRecordingDownloadUrl = (url: string) => {
const lowerUrl = url.toLowerCase();
return lowerUrl.includes('recording')
|| lowerUrl.includes('recordings')
|| lowerUrl.includes('download')
|| lowerUrl.includes('export')
|| lowerUrl.includes('json')
|| lowerUrl.endsWith('.json');
};
export default ({ route }) => { export default ({ route }) => {
if (!route.params) { if (!route.params) {
return null; return null;
} }
const { uri, useExitButton = true } = route.params; const {
uri,
useExitButton = true,
importRecordingDownloads = false,
} = route.params;
const { goBack } = useNavigation(); const { goBack } = useNavigation();
const { fixed } = useThemeColors(); const { fixed } = useThemeColors();
const { importRecordingsFromText } = useTrainMenu();
const webViewRef = React.useRef<WebView>(null); const webViewRef = React.useRef<WebView>(null);
const [canGoBack, setCanGoBack] = React.useState(false); const [canGoBack, setCanGoBack] = React.useState(false);
const nativeCanGoBackRef = React.useRef(false);
const historyStackRef = React.useRef<string[]>([]);
const [selectedEnvironment, setSelectedEnvironment] = React.useState( const [selectedEnvironment, setSelectedEnvironment] = React.useState(
DEFAULT_JR_DATA_SYSTEM_ENV, DEFAULT_JR_DATA_SYSTEM_ENV,
); );
@@ -35,8 +138,6 @@ export default ({ route }) => {
const [errorMessage, setErrorMessage] = React.useState(""); const [errorMessage, setErrorMessage] = React.useState("");
// WebViewをforce remountするためのkey // WebViewをforce remountするためのkey
const [webViewKey, setWebViewKey] = React.useState(0); const [webViewKey, setWebViewKey] = React.useState(0);
// バックグラウンド移行時刻の記録
const backgroundedAt = React.useRef<number | null>(null);
// RN-side watchdog: WebViewプロセスの死活確認用 // RN-side watchdog: WebViewプロセスの死活確認用
const lastPongAt = React.useRef<number>(Date.now()); const lastPongAt = React.useRef<number>(Date.now());
const isLoadingRef = React.useRef(true); const isLoadingRef = React.useRef(true);
@@ -62,12 +163,15 @@ export default ({ route }) => {
const applyEnvironment = (value: unknown) => { const applyEnvironment = (value: unknown) => {
if (!isMounted) return; if (!isMounted) return;
const nextEnvironment = normalizeJrDataSystemEnvironment(value); const nextEnvironment = normalizeJrDataSystemEnvironment(value);
const rawUri = typeof uri === "string" ? uri : "";
historyStackRef.current = [];
nativeCanGoBackRef.current = false;
setCanGoBack(false);
setSelectedEnvironment(nextEnvironment); setSelectedEnvironment(nextEnvironment);
setResolvedUri( setResolvedUri(
rewriteJrDataSystemUrl( importRecordingDownloads
typeof uri === "string" ? uri : "", ? rawUri
nextEnvironment, : rewriteJrDataSystemUrl(rawUri, nextEnvironment),
),
); );
setIsEnvironmentReady(true); setIsEnvironmentReady(true);
}; };
@@ -79,7 +183,7 @@ export default ({ route }) => {
return () => { return () => {
isMounted = false; isMounted = false;
}; };
}, [uri]); }, [uri, importRecordingDownloads]);
const handleReload = () => { const handleReload = () => {
lastPongAt.current = Date.now(); lastPongAt.current = Date.now();
@@ -90,29 +194,84 @@ export default ({ route }) => {
setWebViewKey((k) => k + 1); setWebViewKey((k) => k + 1);
}; };
// AppState監視: バックグラウンド10秒超で復帰したらWebViewを再マウント const syncCanGoBack = React.useCallback((nativeCanGoBack: boolean) => {
React.useEffect(() => { nativeCanGoBackRef.current = nativeCanGoBack;
const onAppStateChange = (nextState: AppStateStatus) => { setCanGoBack(nativeCanGoBack || historyStackRef.current.length > 1);
if (nextState.match(/inactive|background/)) { }, []);
backgroundedAt.current = Date.now();
} else if (nextState === "active" && backgroundedAt.current !== null) { const pushHistoryEntry = React.useCallback((url: string) => {
const elapsed = Date.now() - backgroundedAt.current; if (!url) return;
backgroundedAt.current = null; const stack = historyStackRef.current;
if (elapsed > 10_000) { if (stack[stack.length - 1] === url) return;
remount(); stack.push(url);
} if (stack.length > 30) {
stack.splice(0, stack.length - 30);
}
}, []);
const handlePseudoBack = React.useCallback(() => {
const stack = historyStackRef.current;
if (stack.length <= 1) return false;
stack.pop();
const previousUrl = stack[stack.length - 1];
if (!previousUrl) return false;
setResolvedUri(previousUrl);
syncCanGoBack(false);
remount();
return true;
}, [remount, syncCanGoBack]);
const handleWebViewBack = React.useCallback(() => {
if (nativeCanGoBackRef.current) {
webViewRef.current?.goBack();
return true;
}
return handlePseudoBack();
}, [handlePseudoBack]);
const handleImportRecordingText = React.useCallback(
async (content: string) => {
try {
const result = await importRecordingsFromText(content);
Alert.alert(
"録画データを読み込みました",
result.overwrittenCount > 0
? `${result.importedCount}件を読み込みました。${result.overwrittenCount}件は同じIDのため上書きしました。`
: `${result.importedCount}件を読み込みました。`,
);
} catch (error) {
Alert.alert(
"録画データを読み込めませんでした",
(error as Error).message || "ファイル内容を確認してください。",
);
} }
}; },
const subscription = AppState.addEventListener("change", onAppStateChange); [importRecordingsFromText],
return () => subscription.remove(); );
}, [remount]);
const handleImportRecordingUrl = React.useCallback(
async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
await handleImportRecordingText(await response.text());
} catch (error) {
Alert.alert(
"録画データを取得できませんでした",
(error as Error).message || "通信状態を確認してください。",
);
}
},
[handleImportRecordingText],
);
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
const onHardwareBack = () => { const onHardwareBack = () => {
if (canGoBack) { if (canGoBack) {
webViewRef.current?.goBack(); if (handleWebViewBack()) return true;
return true;
} }
goBack(); goBack();
return true; return true;
@@ -120,7 +279,7 @@ export default ({ route }) => {
const subscription = BackHandler.addEventListener("hardwareBackPress", onHardwareBack); const subscription = BackHandler.addEventListener("hardwareBackPress", onHardwareBack);
return () => subscription.remove(); return () => subscription.remove();
}, [canGoBack, goBack]) }, [canGoBack, goBack, handleWebViewBack])
); );
return ( return (
<View style={{ height: "100%", backgroundColor: fixed.primary }}> <View style={{ height: "100%", backgroundColor: fixed.primary }}>
@@ -130,8 +289,11 @@ export default ({ route }) => {
source={{ uri: resolvedUri }} source={{ uri: resolvedUri }}
contentMode="mobile" contentMode="mobile"
allowsBackForwardNavigationGestures allowsBackForwardNavigationGestures
setSupportMultipleWindows={false}
ref={webViewRef} ref={webViewRef}
injectedJavaScriptBeforeContentLoaded={`true;`} injectedJavaScriptBeforeContentLoaded={
importRecordingDownloads ? RECORDING_DOWNLOAD_BRIDGE_SCRIPT : `true;`
}
onLoadStart={() => { onLoadStart={() => {
isLoadingRef.current = true; isLoadingRef.current = true;
setHasError(false); setHasError(false);
@@ -173,34 +335,77 @@ export default ({ route }) => {
return true; return true;
} }
const rewrittenUrl = rewriteJrDataSystemUrl( if (
request.url, importRecordingDownloads &&
selectedEnvironment, isLikelyRecordingDownloadUrl(request.url) &&
); request.url !== resolvedUri
if (rewrittenUrl !== request.url) { ) {
setResolvedUri(rewrittenUrl); void handleImportRecordingUrl(request.url);
return false; return false;
} }
if (!importRecordingDownloads) {
const rewrittenUrl = rewriteJrDataSystemUrl(
request.url,
selectedEnvironment,
);
if (rewrittenUrl !== request.url) {
setResolvedUri(rewrittenUrl);
return false;
}
}
return true; return true;
}} }}
onNavigationStateChange={(navState) => { onFileDownload={(event) => {
setCanGoBack(navState.canGoBack); if (!importRecordingDownloads) return;
// SPA内遷移中は白画面誤検知を防ぐためblankCountをリセット const downloadUrl = event.nativeEvent.downloadUrl;
if (navState.loading) blankCountRef.current = 0; if (downloadUrl) {
if (navState.url === "https://unyohub.2pd.jp/integration/succeeded.php") { void handleImportRecordingUrl(downloadUrl);
webViewRef.current?.goBack();
if (!hasAlerted.current) {
hasAlerted.current = true;
Alert.alert("鉄道運用HUBへの投稿完了", "運用HUBからのこのアプリへのデータ反映には暫く時間がかかりますので、しばらくお待ちください。", [
{ text: "完了" },
]);
} }
} }}
}} onNavigationStateChange={(navState) => {
if (navState.url) {
const nextUrl = importRecordingDownloads
? navState.url
: rewriteJrDataSystemUrl(navState.url, selectedEnvironment);
setResolvedUri((current) => (current === nextUrl ? current : nextUrl));
if (nextUrl !== "https://unyohub.2pd.jp/integration/succeeded.php") {
pushHistoryEntry(nextUrl);
}
}
syncCanGoBack(navState.canGoBack);
// SPA内遷移中は白画面誤検知を防ぐためblankCountをリセット
if (navState.loading) blankCountRef.current = 0;
if (navState.url === "https://unyohub.2pd.jp/integration/succeeded.php") {
webViewRef.current?.goBack();
if (!hasAlerted.current) {
hasAlerted.current = true;
Alert.alert("鉄道運用HUBへの投稿完了", "運用HUBからのこのアプリへのデータ反映には暫く時間がかかりますので、しばらくお待ちください。", [
{ text: "完了" },
]);
}
}
}}
onMessage={(event) => { onMessage={(event) => {
const { data } = event.nativeEvent; const { data } = event.nativeEvent;
const parsed = JSON.parse(data); let parsed: any;
try {
parsed = JSON.parse(data);
} catch {
return;
}
const { type } = parsed; const { type } = parsed;
if (type === "importRecordingDownload") {
void handleImportRecordingText(String(parsed.text ?? ""));
return;
}
if (type === "importRecordingDownloadError") {
Alert.alert(
"録画データを取得できませんでした",
parsed.message || "ダウンロード内容を読み取れませんでした。",
);
return;
}
if (type === "pong") { if (type === "pong") {
lastPongAt.current = Date.now(); lastPongAt.current = Date.now();
const bodyLen: number = parsed.bodyLen ?? 0; const bodyLen: number = parsed.bodyLen ?? 0;
@@ -235,7 +440,7 @@ export default ({ route }) => {
})(); })();
return; return;
} }
if (type === "back") return webViewRef.current?.goBack(); if (type === "back") return handleWebViewBack();
if (type === "windowClose") return goBack(); if (type === "windowClose") return goBack();
}} }}
/> />
+82 -12
View File
@@ -18,11 +18,12 @@ import Setting from "@/components/Settings/settings";
import { useFavoriteStation } from "@/stateBox/useFavoriteStation"; import { useFavoriteStation } from "@/stateBox/useFavoriteStation";
import { optionData } from "@/lib/stackOption"; import { optionData } from "@/lib/stackOption";
import { AllTrainDiagramView } from "@/components/AllTrainDiagramView"; import { AllTrainDiagramView } from "@/components/AllTrainDiagramView";
import { useNavigation } from "@react-navigation/native"; import { useNavigation, useIsFocused } from "@react-navigation/native";
import { news } from "@/config/newsUpdate"; import { news } from "@/config/newsUpdate";
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs"; import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
import GeneralWebView from "@/GeneralWebView"; import GeneralWebView from "@/GeneralWebView";
import { StationDiagramView } from "@/components/StationDiagram/StationDiagramView"; import { StationDiagramView } from "@/components/StationDiagram/StationDiagramView";
import * as Sentry from "@sentry/react-native";
const Stack = createStackNavigator(); const Stack = createStackNavigator();
export function MenuPage() { export function MenuPage() {
@@ -32,18 +33,38 @@ export function MenuPage() {
const tabBarHeight = useBottomTabBarHeight(); const tabBarHeight = useBottomTabBarHeight();
const navigation = useNavigation<any>(); const navigation = useNavigation<any>();
const { addListener } = navigation; const { addListener } = navigation;
const isFocused = useIsFocused();
const isDark = useColorScheme() === "dark"; const isDark = useColorScheme() === "dark";
const bgColor = isDark ? "#1c1c1e" : "#ffffff"; const bgColor = isDark ? "#1c1c1e" : "#ffffff";
useEffect(() => {
AS.getItem(STORAGE_KEYS.START_PAGE)
.then((res) => {
if (res == "true") navigation.navigate("positions");
})
.catch((e) => {
//6.0以降false
AS.setItem(STORAGE_KEYS.START_PAGE, "false");
});
useEffect(() => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu mounted",
});
return () => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu unmounted",
});
};
}, []);
useEffect(() => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: isFocused ? "top menu focused" : "top menu blurred",
data: {
width,
height,
},
});
}, [height, isFocused, width]);
useEffect(() => {
//ニュース表示 //ニュース表示
AS.getItem(STORAGE_KEYS.NEWS_STATUS) AS.getItem(STORAGE_KEYS.NEWS_STATUS)
.then((d) => { .then((d) => {
@@ -55,10 +76,20 @@ export function MenuPage() {
if (isSetIcon == "true") SheetManager.show("TrainIconUpdate"); if (isSetIcon == "true") SheetManager.show("TrainIconUpdate");
}) })
.catch((error) => logger.error("Error fetching icon setting:", error)); .catch((error) => logger.error("Error fetching icon setting:", error));
return undefined;
}, []); }, []);
const scrollRef = useRef(null); const scrollRef = useRef(null);
const [mapMode, setMapMode] = useState(false); const [mapMode, setMapMode] = useState(false);
useEffect(() => {
Sentry.setContext("top_menu_screen", {
focused: isFocused,
width,
height,
mapMode,
});
}, [height, isFocused, mapMode, width]);
const [mapHeight, setMapHeight] = useState(0); const [mapHeight, setMapHeight] = useState(0);
const mapHeightRef = useRef(0); const mapHeightRef = useRef(0);
const favoriteStationRef = useRef(favoriteStation); const favoriteStationRef = useRef(favoriteStation);
@@ -85,6 +116,16 @@ export function MenuPage() {
}, [height, tabBarHeight, width]); }, [height, tabBarHeight, width]);
useEffect(() => { useEffect(() => {
const unsubscribe = addListener("tabPress", (e: any) => { const unsubscribe = addListener("tabPress", (e: any) => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu tabPress handler",
data: {
focused: navigation.isFocused(),
stackIndex: stackNavRef.current?.getState()?.index ?? null,
mapMode,
},
});
if (navigation.isFocused() && stackNavRef.current) { if (navigation.isFocused() && stackNavRef.current) {
if (stackNavRef.current.getState()?.index > 0) { if (stackNavRef.current.getState()?.index > 0) {
e.preventDefault(); e.preventDefault();
@@ -118,10 +159,39 @@ export function MenuPage() {
return ( return (
<Stack.Navigator <Stack.Navigator
id={null} id={null}
detachInactiveScreens={false}
screenOptions={{ cardStyle: { backgroundColor: bgColor } }} screenOptions={{ cardStyle: { backgroundColor: bgColor } }}
screenListeners={({ navigation: stackNav }) => { screenListeners={({ route, navigation: stackNav }) => {
stackNavRef.current = stackNav; stackNavRef.current = stackNav;
return {}; return {
focus: () => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu stack focus",
data: { route: route.name },
});
},
blur: () => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu stack blur",
data: { route: route.name },
});
},
state: () => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu stack state",
data: {
route: route.name,
stackIndex: stackNav.getState()?.index ?? null,
},
});
},
};
}} }}
> >
<Stack.Screen <Stack.Screen
+182 -11
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { createStackNavigator } from "@react-navigation/stack"; import { createStackNavigator } from "@react-navigation/stack";
import { useNavigation } from "@react-navigation/native"; import { useIsFocused, useNavigation } from "@react-navigation/native";
import { useColorScheme } from "react-native"; import { useColorScheme } from "react-native";
import Apps from "./components/Apps"; import Apps from "./components/Apps";
import TrainBase from "./components/trainbaseview"; import TrainBase from "./components/trainbaseview";
@@ -13,16 +13,20 @@ import { useCurrentTrain } from "./stateBox/useCurrentTrain";
import { useTrainMenu } from "./stateBox/useTrainMenu"; import { useTrainMenu } from "./stateBox/useTrainMenu";
import { AS } from "./storageControl"; import { AS } from "./storageControl";
import { news } from "./config/newsUpdate"; import { news } from "./config/newsUpdate";
import { Linking, Platform } from "react-native"; import { InteractionManager, Linking, Platform, View } from "react-native";
import GeneralWebView from "./GeneralWebView"; import GeneralWebView from "./GeneralWebView";
import { StationDiagramView } from "@/components/StationDiagram/StationDiagramView"; import { StationDiagramView } from "@/components/StationDiagram/StationDiagramView";
import { positionsStackNavRef } from "./lib/rootNavigation"; import { positionsLifecycleRef, positionsStackNavRef } from "./lib/rootNavigation";
import * as Sentry from "@sentry/react-native";
const Stack = createStackNavigator(); const Stack = createStackNavigator();
export const Top = () => { export const Top = () => {
const { webview } = useCurrentTrain(); const { webview } = useCurrentTrain();
const { navigate, addListener, isFocused } = useNavigation<any>(); const navigation = useNavigation<any>();
const { navigate, addListener } = navigation;
const isTabFocused = useIsFocused();
const isDark = useColorScheme() === "dark"; const isDark = useColorScheme() === "dark";
const bgColor = isDark ? "#1c1c1e" : "#ffffff"; const bgColor = isDark ? "#1c1c1e" : "#ffffff";
const [hasActivatedStack, setHasActivatedStack] = useState(false);
//地図用 //地図用
const { mapSwitch } = useTrainMenu(); const { mapSwitch } = useTrainMenu();
@@ -31,24 +35,136 @@ export const Top = () => {
mapSwitchRef.current = mapSwitch; mapSwitchRef.current = mapSwitch;
}, [mapSwitch]); }, [mapSwitch]);
const goToFavoriteList = () => const goToFavoriteList = (event?: any) => {
if (!hasActivatedStack) {
event?.preventDefault?.();
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions tabLongPress ignored before stack activation",
});
return;
}
navigate("positions", { screen: "favoriteList" }); navigate("positions", { screen: "favoriteList" });
};
useEffect(() => { useEffect(() => {
const unsubscribe = addListener("tabLongPress", goToFavoriteList); const unsubscribe = addListener("tabLongPress", goToFavoriteList);
return unsubscribe; return unsubscribe;
}, []); }, []);
useEffect(() => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack mounted",
});
return () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack unmounted",
});
};
}, []);
useEffect(() => {
if (hasActivatedStack) return;
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack activation scheduled",
data: {
reason: isTabFocused ? "first_focus" : "background_prewarm",
},
});
let cancelled = false;
const activate = () => {
if (cancelled) return;
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack activation committed",
data: {
reason: isTabFocused ? "first_focus" : "background_prewarm",
},
});
setHasActivatedStack(true);
};
if (isTabFocused) {
const task = InteractionManager.runAfterInteractions(() => {
setTimeout(activate, 0);
});
return () => {
cancelled = true;
task.cancel?.();
};
}
const timer = setTimeout(activate, 300);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [hasActivatedStack, isTabFocused]);
useEffect(() => {
Sentry.setContext("positions_stack", {
focused: isTabFocused,
activated: hasActivatedStack,
mapSwitch,
});
}, [hasActivatedStack, isTabFocused, mapSwitch]);
const stackNavRef = positionsStackNavRef; const stackNavRef = positionsStackNavRef;
useEffect(() => {
positionsLifecycleRef.current = {
...positionsLifecycleRef.current,
deactivateStack: null,
};
return () => {
positionsLifecycleRef.current = {
...positionsLifecycleRef.current,
deactivateStack: null,
};
};
}, []);
const goToTrainMenu = useCallback((e: any) => { const goToTrainMenu = useCallback((e: any) => {
const stackNav = stackNavRef.current;
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions tabPress handler",
data: {
platform: Platform.OS,
focused: isTabFocused,
mapSwitch: mapSwitchRef.current,
stackIndex: stackNav?.getState()?.index ?? null,
},
});
if (Platform.OS === "web") { if (Platform.OS === "web") {
Linking.openURL("https://train.jr-shikoku.co.jp/"); Linking.openURL("https://train.jr-shikoku.co.jp/");
setTimeout(() => navigate("topMenu", { screen: "menu" }), 100); setTimeout(() => navigate("topMenu", { screen: "menu" }), 100);
return; return;
} }
if (!isFocused()) return; if (!hasActivatedStack || !stackNav) {
const stackNav = stackNavRef.current; e?.preventDefault?.();
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions tabPress ignored before stack activation",
data: {
focused: isTabFocused,
activated: hasActivatedStack,
hasStackNav: !!stackNav,
},
});
return;
}
if (!isTabFocused) return;
if (stackNav && stackNav.getState()?.index > 0) { if (stackNav && stackNav.getState()?.index > 0) {
e.preventDefault(); e.preventDefault();
stackNav.goBack(); stackNav.goBack();
@@ -58,20 +174,75 @@ export const Top = () => {
navigate("positions", { screen: "trainMenu" }); navigate("positions", { screen: "trainMenu" });
else webview.current?.injectJavaScript(`AccordionClassEvent()`); else webview.current?.injectJavaScript(`AccordionClassEvent()`);
return; return;
}, [isFocused, navigate, webview]); }, [hasActivatedStack, isTabFocused, navigate, webview]);
useEffect(() => { useEffect(() => {
const unsubscribe = addListener("tabPress", goToTrainMenu); const unsubscribe = addListener("tabPress", goToTrainMenu);
return unsubscribe; return unsubscribe;
}, [addListener, goToTrainMenu]); }, [addListener, goToTrainMenu]);
if (!hasActivatedStack) {
return <View style={{ flex: 1, backgroundColor: bgColor }} />;
}
return ( return (
<Stack.Navigator <Stack.Navigator
id={null} id={null}
detachInactiveScreens={false}
screenOptions={{ cardStyle: { backgroundColor: bgColor } }} screenOptions={{ cardStyle: { backgroundColor: bgColor } }}
screenListeners={({ navigation: stackNav }) => { screenListeners={({ route, navigation: stackNav }) => {
stackNavRef.current = stackNav; stackNavRef.current = stackNav;
return {}; return {
focus: () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack focus",
data: { route: route.name },
});
},
blur: () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack blur",
data: { route: route.name },
});
},
transitionStart: () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack transitionStart",
data: {
route: route.name,
stackIndex: stackNav.getState()?.index ?? null,
},
});
},
transitionEnd: () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack transitionEnd",
data: {
route: route.name,
stackIndex: stackNav.getState()?.index ?? null,
},
});
},
state: () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack state",
data: {
route: route.name,
stackIndex: stackNav.getState()?.index ?? null,
},
});
},
};
}} }}
> >
<Stack.Screen <Stack.Screen
+25 -9
View File
@@ -8,7 +8,7 @@
"android", "android",
"web" "web"
], ],
"version": "7.0.0", "version": "7.1.0",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"orientation": "default", "orientation": "default",
"icon": "./assets/icons/s8600.png", "icon": "./assets/icons/s8600.png",
@@ -24,7 +24,7 @@
"**/*" "**/*"
], ],
"ios": { "ios": {
"buildNumber": "63", "buildNumber": "67",
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "jrshikokuinfo.xprocess.hrkn", "bundleIdentifier": "jrshikokuinfo.xprocess.hrkn",
"appleTeamId": "54CRDT797G", "appleTeamId": "54CRDT797G",
@@ -33,16 +33,15 @@
}, },
"infoPlist": { "infoPlist": {
"NFCReaderUsageDescription": "To read FeliCa card", "NFCReaderUsageDescription": "To read FeliCa card",
"NSPhotoLibraryUsageDescription": "運行情報画像を共有または保存するために写真ライブラリへのアクセスを使用します。",
"NSPhotoLibraryAddUsageDescription": "運行情報画像を写真ライブラリに保存するために使用します。",
"com.apple.developer.nfc.readersession.felica.systemcodes": [ "com.apple.developer.nfc.readersession.felica.systemcodes": [
"0003", "0003",
"FE00" "FE00"
], ],
"ITSAppUsesNonExemptEncryption": false, "ITSAppUsesNonExemptEncryption": false,
"NSSupportsLiveActivities": true, "NSSupportsLiveActivities": true,
"NSSupportsLiveActivitiesFrequentUpdates": true, "NSSupportsLiveActivitiesFrequentUpdates": true
"UIBackgroundModes": [
"audio"
]
}, },
"entitlements": { "entitlements": {
"com.apple.developer.nfc.readersession.formats": [ "com.apple.developer.nfc.readersession.formats": [
@@ -55,7 +54,7 @@
}, },
"android": { "android": {
"package": "jrshikokuinfo.xprocess.hrkn", "package": "jrshikokuinfo.xprocess.hrkn",
"versionCode": 30, "versionCode": 33,
"intentFilters": [ "intentFilters": [
{ {
"action": "VIEW", "action": "VIEW",
@@ -115,6 +114,7 @@
{ {
"fonts": [ "fonts": [
"./assets/fonts/jr-nishi.otf", "./assets/fonts/jr-nishi.otf",
"./assets/fonts/JR-WEST-PLUS.otf",
"./assets/fonts/DelaGothicOne-Regular.ttf", "./assets/fonts/DelaGothicOne-Regular.ttf",
"./assets/fonts/JNRfont_pict.ttf", "./assets/fonts/JNRfont_pict.ttf",
"./assets/fonts/DiaPro-Regular.otf" "./assets/fonts/DiaPro-Regular.otf"
@@ -131,7 +131,7 @@
[ [
"expo-location", "expo-location",
{ {
"locationWhenInUsePermission": "この位置情報は、リンク画面で現在地近の駅情報を取得するのに使用されます。" "locationWhenInUsePermission": "現在地近の駅表示と、列車追従中に次の停車駅への接近をりっかちゃん音声で通知するために使用ます。"
} }
], ],
[ [
@@ -970,13 +970,29 @@
"expo-web-browser", "expo-web-browser",
"expo-asset", "expo-asset",
"expo-sharing", "expo-sharing",
[
"react-native-share",
{
"ios": [],
"android": [],
"enableBase64ShareAndroid": false
}
],
[ [
"react-native-maps", "react-native-maps",
{ {
"iosGoogleMapsApiKey": "AIzaSyAVGDTjBkR_0wkQiNkoo5WDLhqXCjrjk8Y", "iosGoogleMapsApiKey": "AIzaSyAVGDTjBkR_0wkQiNkoo5WDLhqXCjrjk8Y",
"androidGoogleMapsApiKey": "AIzaSyAmFb-Yj033bXZWlSzNrfq_0jc1PgRrWcE" "androidGoogleMapsApiKey": "AIzaSyAmFb-Yj033bXZWlSzNrfq_0jc1PgRrWcE"
} }
],
[
"@sentry/react-native/expo",
{
"url": "https://sentry.io/",
"project": "jr-shikoku-unofficial-apps",
"organization": "xprocess-m5"
}
] ]
] ]
} }
} }
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
西日本方向ロゴ拡張「JR-WEST-PLUS」
2026/07/11 by Mameh1nata
「回送」とか専用の書体の方がかっこよくね?と思い制作したフォントです
現時点で対応している種別は以下の通りです
回送 試運転 貸切 修学旅行 救援 配給 訓練 検測 競技 検定 教習
ver1.0 プライベート公開
ver1.01 修学旅行を追加
ver1.1 救援 配給 訓練の追加
ver1.2 競技 検定 教習の追加
-注意-
・このフォントは西日本方向幕ロゴの制作者とは何の関係もありません
・常識の範囲内で使うことをお勧めします(常識の範囲外でも自己責任でお願いします)
・文字サイズが多少違ったりするかもしれませんがご了承ください
・フォント初制作なのであまりにも致命的なミス以外は許してください
配布元: https://uu.getuploader.com/mameh1nata_dustbox/download/1
アプリ同梱版の調整:
・JR-Nishi と字面を揃えるため、各グリフの表示領域を 1040 units に正規化
・送り幅は 930 units とし、広げた字形の右側はアプリ側で小幅の DiaPro NBSP を付加して保護
・縦の表示領域を 836 units、ベースラインを -86〜750 に正規化
・フォント全体の ascender / descender を JR-Nishi と同じ 781 / -118 に調整
・文字コードと内部フォント名は元ファイルから維持
@@ -1,4 +1,4 @@
import React, { FC, useMemo, useState } from "react"; import React, { FC, useMemo } from "react";
import { Text, View, TextStyle, TouchableOpacity } from "react-native"; import { Text, View, TextStyle, TouchableOpacity } from "react-native";
import { SheetManager } from "react-native-actions-sheet"; import { SheetManager } from "react-native-actions-sheet";
import { migrateTrainName } from "../../../lib/eachTrainInfoCoreLib/migrateTrainName"; import { migrateTrainName } from "../../../lib/eachTrainInfoCoreLib/migrateTrainName";
@@ -16,6 +16,7 @@ import type { NavigateFunction } from "@/types";
import { useUnyohub } from "@/stateBox/useUnyohub"; import { useUnyohub } from "@/stateBox/useUnyohub";
import { useElesite } from "@/stateBox/useElesite"; import { useElesite } from "@/stateBox/useElesite";
import { useThemeColors } from "@/lib/theme"; import { useThemeColors } from "@/lib/theme";
import { formatWrappedTrainType, shouldWrapTrainType } from "@/lib/trainTypeWrap";
import { useResponsive } from "@/lib/responsive"; import { useResponsive } from "@/lib/responsive";
import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode"; import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
@@ -75,7 +76,7 @@ export const HeaderText: FC<Props> = ({
const [ const [
typeName, typeName,
trainName, trainName,
fontAvailable, fontFamily,
isOneMan, isOneMan,
infogram, infogram,
priority, priority,
@@ -96,7 +97,7 @@ export const HeaderText: FC<Props> = ({
to_data, to_data,
directions, directions,
} = result; } = result;
const [typeString, fontAvailable, isOneMan] = getStringConfig( const [typeString, fontFamily, isOneMan] = getStringConfig(
type, type,
trainNum, trainNum,
); );
@@ -108,7 +109,7 @@ export const HeaderText: FC<Props> = ({
(train_num_distance !== "" && !isNaN(parseInt(train_num_distance)) (train_num_distance !== "" && !isNaN(parseInt(train_num_distance))
? ` ${parseInt(trainNum) - parseInt(train_num_distance)}` ? ` ${parseInt(trainNum) - parseInt(train_num_distance)}`
: ""), : ""),
fontAvailable, fontFamily,
isOneMan, isOneMan,
infogram, infogram,
priority, priority,
@@ -121,7 +122,7 @@ export const HeaderText: FC<Props> = ({
return [ return [
typeString, typeString,
"", "",
fontAvailable, fontFamily,
isOneMan, isOneMan,
infogram, infogram,
priority, priority,
@@ -134,7 +135,7 @@ export const HeaderText: FC<Props> = ({
return [ return [
typeString, typeString,
`${to_data}行き`, `${to_data}行き`,
fontAvailable, fontFamily,
isOneMan, isOneMan,
infogram, infogram,
priority, priority,
@@ -149,7 +150,7 @@ export const HeaderText: FC<Props> = ({
migrateTrainName( migrateTrainName(
`${trainData[trainData.length - 1].split(",")[0]}行き`, `${trainData[trainData.length - 1].split(",")[0]}行き`,
), ),
fontAvailable, fontFamily,
isOneMan, isOneMan,
infogram, infogram,
priority, priority,
@@ -211,7 +212,7 @@ export const HeaderText: FC<Props> = ({
hasUnyohubFormation || hasUnyohubFormation ||
hasElesiteFormation; hasElesiteFormation;
const [isWrapped, setIsWrapped] = useState(false); const wrapType = shouldWrapTrainType(typeName, trainName);
const openTrainInfoUrl = () => { const openTrainInfoUrl = () => {
if (!trainInfoUrl) return; if (!trainInfoUrl) return;
@@ -251,15 +252,23 @@ export const HeaderText: FC<Props> = ({
}} }}
> >
<Text <Text
numberOfLines={wrapType ? 2 : 1}
style={{ style={{
fontSize: fontScale(20), fontSize: fontScale(20),
color: fixed.textOnPrimary, color: fixed.textOnPrimary,
fontFamily: fontAvailable ? "JR-Nishi" : undefined, fontFamily,
fontWeight: !fontAvailable ? "bold" : undefined, fontWeight: fontFamily ? undefined : "bold",
marginRight: 5, marginRight: 5,
flexShrink: 0,
...(wrapType
? { width: fontScale(44), lineHeight: fontScale(22) }
: {}),
}} }}
> >
{isWrapped ? typeName.replace(/(.{2})/g, "$1\n").trim() : typeName} {wrapType ? formatWrappedTrainType(typeName) : typeName}
{fontFamily === "JR-WEST-PLUS" ? (
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
) : null}
</Text> </Text>
{isOneMan && <OneManText />} {isOneMan && <OneManText />}
</View> </View>
@@ -291,9 +300,6 @@ export const HeaderText: FC<Props> = ({
: { fontSize: fontScale(17) }), : { fontSize: fontScale(17) }),
flexShrink: 1, flexShrink: 1,
}} }}
onTextLayout={(e) => {
if (e.nativeEvent.lines.length > 1) setIsWrapped(true);
}}
> >
{trainName} {trainName}
</Text> </Text>
@@ -1,11 +1,12 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { lineListPair, stationIDPair } from '@/lib/getStationList'; import { lineListPair, stationIDPair } from '@/lib/getStationList';
import { useStationList } from '@/stateBox/useStationList'; import { useStationList } from '@/stateBox/useStationList';
import { OriginalStationList } from '@/lib/CommonTypes';
const computeThroughStations = ( const computeThroughStations = (
trainData: string[], trainData: string[],
stationList: any[][], stationList: any[][],
originalStationList: Record<string, any[]> originalStationList: OriginalStationList
): { trainDataWithThrough: string[]; haveThrough: boolean } => { ): { trainDataWithThrough: string[]; haveThrough: boolean } => {
if (!trainData.length) return { trainDataWithThrough: [], haveThrough: false }; if (!trainData.length) return { trainDataWithThrough: [], haveThrough: false };
@@ -48,7 +48,9 @@ export const StationDeteilView = (props) => {
const [usePDFView, setUsePDFView] = useState(undefined); const [usePDFView, setUsePDFView] = useState(undefined);
useEffect(() => { useEffect(() => {
AS.getItem(STORAGE_KEYS.USE_PDF_VIEW).then(setUsePDFView); AS.getItem(STORAGE_KEYS.USE_PDF_VIEW)
.then(setUsePDFView)
.catch(() => setUsePDFView("false"));
}, []); }, []);
const info = const info =
currentStation && currentStation &&
@@ -1082,7 +1082,7 @@ const TrainInfoDetail: FC<{
uwasa, uwasa,
optional_text, optional_text,
} = data; } = data;
const { fontAvailable } = getTrainType({ const { fontFamily } = getTrainType({
type: data.type, type: data.type,
whiteMode: false, whiteMode: false,
}); });
@@ -1120,13 +1120,16 @@ const TrainInfoDetail: FC<{
style={[ style={[
styles.typeTagText, styles.typeTagText,
{ {
fontFamily: fontAvailable ? "JR-Nishi" : undefined, fontFamily,
fontWeight: !fontAvailable ? "bold" : undefined, fontWeight: fontFamily ? undefined : "bold",
paddingTop: fontAvailable ? 2 : 0, paddingTop: fontFamily ? 2 : 0,
}, },
]} ]}
> >
{typeName} {typeName}
{fontFamily === "JR-WEST-PLUS" ? (
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
) : null}
</Text> </Text>
</View> </View>
)} )}
+14 -13
View File
@@ -28,6 +28,7 @@ import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
import { InfogramText } from "./ActionSheetComponents/EachTrainInfoCore/HeaderTextParts/InfogramText"; import { InfogramText } from "./ActionSheetComponents/EachTrainInfoCore/HeaderTextParts/InfogramText";
import { resolveTrainIconEntries } from "@/lib/trainIconEntries"; import { resolveTrainIconEntries } from "@/lib/trainIconEntries";
import { TrainIconStack } from "./ActionSheetComponents/EachTrainInfoCore/TrainIconStack"; import { TrainIconStack } from "./ActionSheetComponents/EachTrainInfoCore/TrainIconStack";
import { formatWrappedTrainType, shouldWrapTrainType } from "@/lib/trainTypeWrap";
export const AllTrainDiagramView: FC = () => { export const AllTrainDiagramView: FC = () => {
const { colors, fixed } = useThemeColors(); const { colors, fixed } = useThemeColors();
@@ -107,9 +108,7 @@ export const AllTrainDiagramView: FC = () => {
if (directions != undefined) { if (directions != undefined) {
iconTrainDirection = directions ? true : false; iconTrainDirection = directions ? true : false;
} }
const [isWrapped, setIsWrapped] = useState(false); const [typeString, fontFamily, isOneMan] = getStringConfig(type, id);
const [typeString, fontAvailable, isOneMan] = getStringConfig(type, id);
const trainNameString = (() => { const trainNameString = (() => {
switch (true) { switch (true) {
@@ -134,6 +133,7 @@ export const AllTrainDiagramView: FC = () => {
return migrateTrainName(hoge + "行き"); return migrateTrainName(hoge + "行き");
} }
})(); })();
const wrapType = shouldWrapTrainType(typeString, trainNameString);
return ( return (
<TouchableOpacity <TouchableOpacity
style={{ style={{
@@ -164,17 +164,23 @@ export const AllTrainDiagramView: FC = () => {
> >
{typeString && ( {typeString && (
<Text <Text
numberOfLines={wrapType ? 2 : 1}
style={{ style={{
fontSize: 20, fontSize: 20,
color: fixed.textOnPrimary, color: fixed.textOnPrimary,
fontFamily: fontAvailable ? "JR-Nishi" : undefined, fontFamily,
fontWeight: !fontAvailable ? "bold" : undefined, fontWeight: fontFamily ? undefined : "bold",
marginRight: 5, marginRight: 5,
flexShrink: 0,
...(wrapType
? { width: 44, lineHeight: 22 }
: {}),
}} }}
> >
{isWrapped {wrapType ? formatWrappedTrainType(typeString) : typeString}
? typeString.replace(/(.{2})/g, "$1\n").trim() {fontFamily === "JR-WEST-PLUS" ? (
: typeString} <Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
) : null}
</Text> </Text>
)} )}
{isOneMan && <OneManText />} {isOneMan && <OneManText />}
@@ -195,11 +201,6 @@ export const AllTrainDiagramView: FC = () => {
color: fixed.textOnPrimary, color: fixed.textOnPrimary,
flexShrink: 1, flexShrink: 1,
}} }}
onTextLayout={(e) => {
if (e.nativeEvent.lines.length > 1) {
setIsWrapped(true);
}
}}
> >
{trainNameString} {trainNameString}
</Text> </Text>
+275 -36
View File
@@ -1,10 +1,12 @@
import React from "react"; import React from "react";
import { InteractionManager } from "react-native";
import { import {
View, View,
Platform, Platform,
useWindowDimensions, useWindowDimensions,
StatusBar, StatusBar,
useColorScheme, useColorScheme,
Button,
} from "react-native"; } from "react-native";
import * as Updates from "expo-updates"; import * as Updates from "expo-updates";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -23,9 +25,11 @@ import { NewMenu } from "./Apps/NewMenu";
import { MapsButton } from "./Apps/MapsButton"; import { MapsButton } from "./Apps/MapsButton";
import { ReloadButton } from "./Apps/ReloadButton"; import { ReloadButton } from "./Apps/ReloadButton";
import { useStationList } from "../stateBox/useStationList"; import { useStationList } from "../stateBox/useStationList";
import { positionsLifecycleRef } from "../lib/rootNavigation";
import { FixedPositionBox } from "./Apps/FixedPositionBox"; import { FixedPositionBox } from "./Apps/FixedPositionBox";
import { PlaybackTimeline } from "./Apps/PlaybackTimeline"; import { PlaybackTimeline } from "./Apps/PlaybackTimeline";
import { RecordingStatusBar } from "./Apps/RecordingStatusBar"; import { RecordingStatusBar } from "./Apps/RecordingStatusBar";
import * as Sentry from "@sentry/react-native";
export default function Apps() { export default function Apps() {
const { webview, fixedPosition, setFixedPosition } = useCurrentTrain(); const { webview, fixedPosition, setFixedPosition } = useCurrentTrain();
@@ -33,11 +37,15 @@ export default function Apps() {
const { navigate } = useNavigation<any>(); const { navigate } = useNavigation<any>();
const { isLandscape } = useDeviceOrientationChange(); const { isLandscape } = useDeviceOrientationChange();
const { top } = useSafeAreaInsets(); const { top } = useSafeAreaInsets();
const handleLayout = () => {};
const { originalStationList } = useStationList(); const { originalStationList } = useStationList();
const { mapSwitch, trainInfo, setTrainInfo, selectedLine } = useTrainMenu(); const { mapSwitch, trainInfo, setTrainInfo, selectedLine } = useTrainMenu();
const isDark = useColorScheme() === "dark"; const isDark = useColorScheme() === "dark";
const isFocused = useIsFocused(); const isFocused = useIsFocused();
const [hasActivatedScreen, setHasActivatedScreen] = React.useState(false);
const [hasActivatedWebView, setHasActivatedWebView] = React.useState(false);
const [hasStableWebViewSession, setHasStableWebViewSession] = React.useState(false);
const lastFocusSignatureRef = React.useRef("");
const lastLayoutSignatureRef = React.useRef("");
const lineColor = selectedLine && stationIDPair[selectedLine] const lineColor = selectedLine && stationIDPair[selectedLine]
? lineColorList[stationIDPair[selectedLine]] ? lineColorList[stationIDPair[selectedLine]]
@@ -84,20 +92,219 @@ export default function Apps() {
} }
}; };
const bgColor = isDark ? "#1c1c1e" : "#ffffff"; const bgColor = isDark ? "#1c1c1e" : "#ffffff";
const resetTransientPositionsSession = React.useCallback(() => {
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: hasStableWebViewSession
? "positions stable session preserved before leave"
: "positions transient session reset before leave",
data: {
stableWebViewSession: hasStableWebViewSession,
},
});
positionsLifecycleRef.current = {
...positionsLifecycleRef.current,
isUnstable: false,
blockTabExit: false,
};
if (hasStableWebViewSession) {
return;
}
try {
webview.current?.stopLoading?.();
} catch {}
setHasActivatedWebView(false);
setHasActivatedScreen(false);
setHasStableWebViewSession(false);
}, [hasStableWebViewSession, webview]);
React.useEffect(() => {
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions screen mounted",
});
return () => {
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions screen unmounted",
});
};
}, []);
React.useEffect(() => {
if (!isFocused) {
if (!hasActivatedWebView && !hasStableWebViewSession) {
// allow one hidden startup prewarm so the positions session can begin before first tab focus
} else {
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions webview preserved on blur",
data: {
stableWebViewSession: hasStableWebViewSession,
activatedWebView: hasActivatedWebView,
},
});
return;
}
}
if (hasActivatedScreen && hasActivatedWebView) return;
const activationReason = isFocused
? (hasActivatedScreen ? "focus" : "first_focus")
: "background_prewarm";
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions screen activation scheduled",
data: {
reason: activationReason,
},
});
let cancelled = false;
const activate = () => {
if (cancelled) {
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions screen activation cancelled before webview mount",
data: {
reason: activationReason,
},
});
return;
}
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions screen activation committed",
data: {
reason: activationReason,
},
});
setHasActivatedScreen(true);
setHasActivatedWebView(true);
};
if (isFocused) {
const task = InteractionManager.runAfterInteractions(() => {
setTimeout(activate, 250);
});
return () => {
cancelled = true;
task.cancel?.();
};
}
const timer = setTimeout(activate, 350);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [hasActivatedScreen, hasActivatedWebView, hasStableWebViewSession, isFocused]);
React.useEffect(() => {
const shouldGuardTabExit = isFocused && hasActivatedWebView && !hasStableWebViewSession;
positionsLifecycleRef.current = {
isUnstable: shouldGuardTabExit,
blockTabExit: shouldGuardTabExit,
resetBeforeLeave: resetTransientPositionsSession,
deactivateStack: positionsLifecycleRef.current.deactivateStack,
};
return () => {
positionsLifecycleRef.current = {
isUnstable: false,
blockTabExit: false,
resetBeforeLeave: null,
deactivateStack: positionsLifecycleRef.current.deactivateStack,
};
};
}, [hasActivatedWebView, hasStableWebViewSession, isFocused, resetTransientPositionsSession]);
React.useEffect(() => {
Sentry.setContext("positions_screen", {
focused: isFocused,
activatedScreen: hasActivatedScreen,
activatedWebView: hasActivatedWebView,
stableWebViewSession: hasStableWebViewSession,
mapSwitch,
selectedLine: selectedLine ?? null,
trainNum: trainInfo?.trainNum ?? null,
landscape: isLandscape,
width,
height,
});
}, [hasActivatedScreen, hasActivatedWebView, hasStableWebViewSession, height, isFocused, isLandscape, mapSwitch, selectedLine, trainInfo?.trainNum, width]);
React.useEffect(() => {
const signature = JSON.stringify({
focused: isFocused,
mapSwitch,
selectedLine: selectedLine ?? null,
trainNum: trainInfo?.trainNum ?? null,
landscape: isLandscape,
});
if (lastFocusSignatureRef.current === signature) return;
lastFocusSignatureRef.current = signature;
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: isFocused ? "positions focused" : "positions blurred",
data: {
mapSwitch,
selectedLine: selectedLine ?? null,
trainNum: trainInfo?.trainNum ?? null,
landscape: isLandscape,
},
});
}, [hasActivatedWebView, isFocused, isLandscape, mapSwitch, selectedLine, trainInfo?.trainNum]);
const handleLayout = React.useCallback((event) => {
const layout = event?.nativeEvent?.layout;
if (!layout) return;
const signature = [Math.round(layout.width), Math.round(layout.height), isLandscape ? "landscape" : "portrait"].join(":");
if (lastLayoutSignatureRef.current === signature) return;
lastLayoutSignatureRef.current = signature;
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions layout",
data: {
width: Math.round(layout.width),
height: Math.round(layout.height),
landscape: isLandscape,
},
});
}, [isLandscape]);
return ( return (
<View style={{ flex: 1, backgroundColor: bgColor }}> <View style={{ flex: 1, backgroundColor: bgColor }}>
{isFocused && mapSwitch === "true" && lineColor && lineColorDark && (
<LinearGradient
colors={[lineColorDark, lineColor]}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={{ position: "absolute", top: 0, left: 0, right: 0, height: top }}
/>
)}
{isFocused && mapSwitch !== "true" && (
<View style={{ position: "absolute", top: 0, left: 0, right: 0, height: top, backgroundColor: "#0099CC" }} />
)}
{isFocused && ( {isFocused && (
<View
pointerEvents="none"
style={{ position: "absolute", top: 0, left: 0, right: 0, height: top }}
>
{mapSwitch === "true" && lineColor && lineColorDark ? (
<LinearGradient
colors={[lineColorDark, lineColor]}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={{ flex: 1 }}
/>
) : (
<View
style={{
flex: 1,
backgroundColor: "#0099CC",
}}
/>
)}
</View>
)}
{isFocused && hasActivatedScreen && (
<StatusBar barStyle="light-content" /> <StatusBar barStyle="light-content" />
)} )}
<View <View
@@ -108,32 +315,64 @@ export default function Apps() {
}} }}
onLayout={handleLayout} onLayout={handleLayout}
> >
<AppsWebView {hasActivatedScreen ? (
{...{ <>
openStationACFromEachTrainInfo, {hasActivatedWebView ? (
}} <AppsWebView
/> openStationACFromEachTrainInfo={openStationACFromEachTrainInfo}
onInitialLoadReady={() => {
if (hasStableWebViewSession) return;
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions webview became stable",
});
positionsLifecycleRef.current = {
...positionsLifecycleRef.current,
isUnstable: false,
blockTabExit: false,
};
setHasStableWebViewSession(true);
}}
/>
) : (
<View style={{ flex: 1 }} />
)}
<MapsButton {isFocused && (
onPress={() => { <MapsButton
navigate("trainMenu", { webview }); onPress={() => {
}} Sentry.addBreadcrumb({
/> category: "positions.screen",
level: "info",
message: "maps button press",
data: {
mapSwitch,
selectedLine: selectedLine ?? null,
hasActivatedWebView,
},
});
navigate("trainMenu", { webview });
}}
/>
)}
{fixedPosition.type && <FixedPositionBox />} {isFocused && fixedPosition.type && <FixedPositionBox />}
<PlaybackTimeline /> {isFocused && <PlaybackTimeline />}
<RecordingStatusBar /> {isFocused && <RecordingStatusBar />}
{isFocused &&
{mapSwitch == "true" ? ( (mapSwitch == "true" ? (
<> <ReloadButton
<ReloadButton onPress={() => Updates.reloadAsync()}
onPress={() => Updates.reloadAsync()} right={isLandscape && trainInfo.trainNum ? (width / 100) * 40 : 0}
right={isLandscape && trainInfo.trainNum ? (width / 100) * 40 : 0} />
/> ) : (
</> <NewMenu />
) : ( ))}
<NewMenu /> </>
)} ) : (
<View style={{ flex: 1 }} />
)}
</View> </View>
</View> </View>
); );
@@ -58,7 +58,7 @@ type props = {
export const FixedStation: FC<props> = ({ stationID }) => { export const FixedStation: FC<props> = ({ stationID }) => {
const { colors, fixed } = useThemeColors(); const { colors, fixed } = useThemeColors();
const { mapSwitch } = useTrainMenu(); const { mapSwitch, playbackCurrentTimeIso } = useTrainMenu();
const { const {
currentTrain, currentTrain,
fixedPosition, fixedPosition,
@@ -151,11 +151,11 @@ export const FixedStation: FC<props> = ({ stationID }) => {
if (!currentTrain) return () => {}; if (!currentTrain) return () => {};
const data = trainTimeAndNumber const data = trainTimeAndNumber
.filter((d) => currentTrain.map((m) => m.num).includes(d.train)) //現在の列車に絞る[ToDo] .filter((d) => currentTrain.map((m) => m.num).includes(d.train)) //現在の列車に絞る[ToDo]
.filter((d) => trainTimeFiltering({ d, currentTrain, station })) //時間フィルター .filter((d) => trainTimeFiltering({ d, currentTrain, station, stationList, now: playbackCurrentTimeIso })) //時間フィルター
.filter((d) => !d.isThrough) .filter((d) => !d.isThrough)
.filter((d) => d.lastStation != station[0].Station_JP); //最終列車表示設定 .filter((d) => d.lastStation != station[0].Station_JP); //最終列車表示設定
setSelectedTrain(data); setSelectedTrain(data);
}, [trainTimeAndNumber, currentTrain, tick /*liveActivity periodic refresh*/]); }, [trainTimeAndNumber, currentTrain, stationList, playbackCurrentTimeIso, tick /*liveActivity periodic refresh*/]);
useEffect(() => { useEffect(() => {
liveNotifyIdRef.current = liveNotifyId; liveNotifyIdRef.current = liveNotifyId;
@@ -259,7 +259,7 @@ export const FixedStation: FC<props> = ({ stationID }) => {
const player = delayAnnouncementPlayerRef.current; const player = delayAnnouncementPlayerRef.current;
setAudioModeAsync({ setAudioModeAsync({
playsInSilentMode: true, playsInSilentMode: true,
shouldPlayInBackground: true, shouldPlayInBackground: false,
interruptionMode: "duckOthers", interruptionMode: "duckOthers",
}) })
.then(() => { .then(() => {
@@ -332,10 +332,14 @@ export const FixedStation: FC<props> = ({ stationID }) => {
}, [selectedTrain, currentTrain, liveNotifyId, buildTrainsInfo]); }, [selectedTrain, currentTrain, liveNotifyId, buildTrainsInfo]);
// バナー表示と同時にLive Activityを自動開始(selectedTrainが揃ってから) // バナー表示と同時にLive Activityを自動開始(selectedTrainが揃ってから)
// iOSのみ一時的に無効化中(Androidは有効)
useEffect(() => { useEffect(() => {
// iOSのLive Activityは無効化 if (
if (Platform.OS === 'ios') return; !isLiveActivityAvailable() ||
hasStartedRef.current ||
station.length === 0
) {
return;
}
hasStartedRef.current = true; hasStartedRef.current = true;
const startActivity = async () => { const startActivity = async () => {
if (Platform.OS === 'android' && Platform.Version >= 33) { if (Platform.OS === 'android' && Platform.Version >= 33) {
+444 -119
View File
@@ -28,17 +28,65 @@ import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { useThemeColors } from "@/lib/theme"; import { useThemeColors } from "@/lib/theme";
import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode"; import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
import { resolveTrainDataIcon } from "@/lib/trainDataIcon"; import { resolveTrainDataIcon } from "@/lib/trainDataIcon";
import { resolveTrainIconEntries } from "@/lib/trainIconEntries";
import { import {
startTrainFollowActivity, startTrainFollowActivity,
updateTrainFollowActivity, updateTrainFollowActivity,
endTrainFollowActivity, endTrainFollowActivity,
isAvailable as isLiveActivityAvailable, isAvailable as isLiveActivityAvailable,
cancelLocationAnnouncements,
} from "expo-live-activity"; } from "expo-live-activity";
import {
DEFAULT_BACKGROUND_RIKKA_TRIGGER_SOURCE,
prepareBackgroundRikkaAnnouncements,
sendTrainPositionRikkaAnnouncement,
} from "@/lib/backgroundRikkaAnnouncements";
import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants";
type props = { type props = {
trainID: string; trainID: string;
}; };
type TrainPathEntry = {
raw: string;
station: string;
se: string;
time: string;
index: number;
isThrough: boolean;
hasTime: boolean;
};
const normalizeTrainPosLabel = (value: string) =>
value
.replace(
/(下り)|(上り)|\(下り\)|\(上り\)|(徳島線)|(高徳線)|(坂出方)|(児島方)/g,
""
)
.trim();
const isHiddenThroughPointEntry = (raw: string) => {
const [station = "", se = ""] = (raw || "").split(",");
return station.startsWith(".") && se.includes("通");
};
const calcDistanceMinute = (
time: string,
delayTime: number,
playbackCurrentTimeIso?: string | null
) => {
if (!time || time === "") return null;
const now = playbackCurrentTimeIso ? dayjs(playbackCurrentTimeIso) : dayjs();
const hour = parseInt(time.split(":")[0], 10);
const target = now
.hour(hour < 4 ? hour + 24 : hour)
.minute(parseInt(time.split(":")[1], 10));
let diff = target.diff(now, "minute") + delayTime;
if (now.hour() < 4 && hour < 4) diff -= 1440;
return diff;
};
export const FixedTrain: FC<props> = ({ trainID }) => { export const FixedTrain: FC<props> = ({ trainID }) => {
const { colors, fixed } = useThemeColors(); const { colors, fixed } = useThemeColors();
const { const {
@@ -50,19 +98,39 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
setFixedPositionSize, setFixedPositionSize,
} = useCurrentTrain(); } = useCurrentTrain();
const { mapSwitch, iconSetting } = useTrainMenu(); const { mapSwitch, iconSetting, playbackCurrentTimeIso } = useTrainMenu();
const { allCustomTrainData, allTrainDiagram } = useAllTrainDiagram(); const { allCustomTrainData, allTrainDiagram, getTodayOperationByTrainId } =
useAllTrainDiagram();
const iconDisplayMode = normalizeIconDisplayMode(iconSetting); const iconDisplayMode = normalizeIconDisplayMode(iconSetting);
const [liveNotifyId, setLiveNotifyId] = useState<string | null>(null); const [liveNotifyId, setLiveNotifyId] = useState<string | null>(null);
const liveNotifyIdRef = useRef<string | null>(null); const liveNotifyIdRef = useRef<string | null>(null);
const hasStartedRef = useRef(false); const hasStartedRef = useRef(false);
const backgroundRikkaSignatureRef = useRef("");
const lastTrainPositionAnnouncementRef = useRef("");
const backgroundRikkaTrackingId = `train-${trainID}`;
const [train, setTrain] = useState<trainDataType>(null); const [train, setTrain] = useState<trainDataType>(null);
const [customData, setCustomData] = useState<CustomTrainData>( const [customData, setCustomData] = useState<CustomTrainData>(
getCurrentTrainData(trainID, currentTrain, allCustomTrainData) getCurrentTrainData(trainID, currentTrain, allCustomTrainData)
); );
const customTrainIcon = resolveTrainDataIcon(customData, iconDisplayMode); const todayOperation = useMemo(
() => (getTodayOperationByTrainId(trainID) ?? []).filter((d) => d.state !== 100),
[getTodayOperationByTrainId, trainID]
);
const customTrainIcon = useMemo(() => {
const iconEntry = resolveTrainIconEntries({
trainNum: trainID,
customTrainData: customData,
todayOperation,
iconDisplayMode,
})[0];
return (
iconEntry?.vehicle_info_img ||
resolveTrainDataIcon(customData, iconDisplayMode)
);
}, [customData, iconDisplayMode, todayOperation, trainID]);
useEffect(() => { useEffect(() => {
setCustomData( setCustomData(
getCurrentTrainData(trainID, currentTrain, allCustomTrainData) getCurrentTrainData(trainID, currentTrain, allCustomTrainData)
@@ -89,8 +157,11 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
); );
const computedTrainDataWithThrough = useMemo(() => { const computedTrainDataWithThrough = useMemo(() => {
const trainData = allTrainDiagram[trainID]?.split("#"); const trainData =
if (!trainData) return []; allTrainDiagram[trainID]
?.split("#")
.filter((entry) => !isHiddenThroughPointEntry(entry)) ?? [];
if (trainData.length === 0) return [];
// 駅名ごとに駅情報を集約するマップを構築 // 駅名ごとに駅情報を集約するマップを構築
const stationByNameMap = new Map(); const stationByNameMap = new Map();
@@ -136,6 +207,8 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
originalStationList[ originalStationList[
lineListPair[stationIDPair[betweenStationLine]] lineListPair[stationIDPair[betweenStationLine]]
].forEach((d) => { ].forEach((d) => {
const isHiddenPassPoint = d.Station_JP?.startsWith(".");
if (isHiddenPassPoint) return;
if ( if (
d.StationNumber > baseStationNumberFirst && d.StationNumber > baseStationNumberFirst &&
d.StationNumber < baseStationNumberSecond d.StationNumber < baseStationNumberSecond
@@ -193,6 +266,22 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
setStopStationList(computedStopStationIDList); setStopStationList(computedStopStationIDList);
}, [computedStopStationIDList]); }, [computedStopStationIDList]);
const [currentPosition, setCurrentPosition] = useState<string[]>([]); const [currentPosition, setCurrentPosition] = useState<string[]>([]);
const parsedTrainPath = useMemo<TrainPathEntry[]>(
() =>
trainDataWidhThrough.map((raw, index) => {
const [station = "", se = "", time = ""] = (raw || "").split(",");
return {
raw,
station,
se,
time,
index,
isThrough: se.includes("通"),
hasTime: time !== "",
};
}),
[trainDataWidhThrough]
);
useEffect(() => { useEffect(() => {
let position = getPosition(train); let position = getPosition(train);
@@ -227,96 +316,127 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
} }
}, [train, stopStationIDList]); }, [train, stopStationIDList]);
const [nextStationData, setNextStationData] = useState<StationProps[]>([]); const [currentPointData, setCurrentPointData] = useState<StationProps[]>([]);
const [untilStationData, setUntilStationData] = useState<StationProps[]>([]); const [nextStopStationData, setNextStopStationData] = useState<StationProps[]>([]);
const [untilStationData, setUntilStationData] = useState<string[]>([]);
const [probably, setProbably] = useState(false); const [probably, setProbably] = useState(false);
const [isCurrentPointDisplay, setIsCurrentPointDisplay] = useState(false);
const [currentDisplayIndex, setCurrentDisplayIndex] = useState(0);
const [tick, setTick] = useState(0);
useEffect(() => {
if (playbackCurrentTimeIso) return;
const interval = setInterval(() => setTick((value) => value + 1), 15000);
return () => clearInterval(interval);
}, [playbackCurrentTimeIso]);
useEffect(() => { useEffect(() => {
//棒線駅判定を入れて、棒線駅なら時間を見て分数がマイナスならcontinue;
const points = findReversalPoints(currentPosition, stopStationIDList); const points = findReversalPoints(currentPosition, stopStationIDList);
if (!points) return; if (!points || points.length === 0) return;
if (points.length == 0) return;
let searchCountFirst = points.findIndex((d) => d == true);
let searchCountLast = points.findLastIndex((d) => d == true);
const delayTime = train?.delay == "入線" ? 0 : train?.delay; const pointIndexes = points.reduce(
let additionalSkipCount = 0; (acc: number[], isCurrent, index) => {
if (isCurrent) acc.push(index);
return acc;
},
[] as number[]
);
if (!pointIndexes.length) return;
// 2駅間走行中の場合: ダイヤ順で後ろのインデックスが進行方向の駅 const firstMatchedIndex = pointIndexes[0];
// Direction に関係なく、travel-order で大きい方が「向かう駅」 const isCurrentStationCluster = currentPosition.length <= 1;
let searchStart: number; const delayTime =
if (currentPosition.length === 2) { train?.delay == "入線" ? 0 : parseInt(String(train?.delay), 10) || 0;
const idx0 = stopStationIDList.findIndex(d => d.includes(currentPosition[0]));
const idx1 = stopStationIDList.findIndex(d => d.includes(currentPosition[1]));
const aheadIdx = Math.max(
idx0 >= 0 ? idx0 : -1,
idx1 >= 0 ? idx1 : -1
);
searchStart = aheadIdx >= 0 ? aheadIdx : searchCountLast;
} else {
searchStart = searchCountFirst;
}
for ( let anchorIndex = firstMatchedIndex;
let searchCount = searchStart; let usedTimeEstimation = false;
searchCount < trainDataWidhThrough.length; const shouldShowCurrentPoint = isCurrentStationCluster;
searchCount++ const hasIntermediateCurrentPoints = pointIndexes.length > 1;
) {
const nextPos = trainDataWidhThrough[searchCount];
if (nextPos) { if (!isCurrentStationCluster && hasIntermediateCurrentPoints) {
const [station, se, time] = nextPos.split(","); let upcomingTimedIndex = -1;
let lastPassedTimedIndex = -1;
// 通過駅はスキップ for (
if (se.includes("通")) { let searchCount = firstMatchedIndex;
searchCount < parsedTrainPath.length;
searchCount++
) {
const entry = parsedTrainPath[searchCount];
if (!entry?.raw || !entry.hasTime) continue;
const distanceMinute = calcDistanceMinute(
entry.time,
delayTime,
playbackCurrentTimeIso
);
if (distanceMinute == null) continue;
if (distanceMinute < 0) {
lastPassedTimedIndex = searchCount;
continue; continue;
} }
// 駅に停車中(1点一致)の場合は時刻判定不要 upcomingTimedIndex = searchCount;
if (searchCountFirst == searchCountLast) { break;
setNextStationData(getStationDataFromName(station));
break;
}
// 2駅間走行中: 時刻で既に通過済みか判定
let distanceMinute = 0;
if (time != "") {
const now = dayjs();
const hour = parseInt(time.split(":")[0]);
const distanceTime = now
.hour(hour < 4 ? hour + 24 : hour)
.minute(parseInt(time.split(":")[1]));
distanceMinute = distanceTime.diff(now, "minute") + delayTime;
if (now.hour() < 4) {
if (hour < 4) {
distanceMinute = distanceMinute - 1440;
}
}
}
if (distanceMinute >= 0) {
setNextStationData(getStationDataFromName(station));
break;
} else {
additionalSkipCount++;
continue;
}
} }
const baseIndex =
lastPassedTimedIndex >= 0 ? lastPassedTimedIndex + 1 : firstMatchedIndex;
if (upcomingTimedIndex >= 0) {
const hasUntimedGapBeforeUpcoming = parsedTrainPath
.slice(baseIndex, upcomingTimedIndex)
.some((entry) => entry?.raw && !entry.hasTime);
anchorIndex = hasUntimedGapBeforeUpcoming ? baseIndex : upcomingTimedIndex;
} else if (lastPassedTimedIndex >= 0) {
const lastPassedEntry = parsedTrainPath[lastPassedTimedIndex];
anchorIndex = lastPassedEntry?.isThrough
? Math.min(lastPassedTimedIndex + 1, parsedTrainPath.length - 1)
: lastPassedTimedIndex;
}
usedTimeEstimation = anchorIndex !== firstMatchedIndex;
} }
let trainList = [];
for ( const anchorEntry = parsedTrainPath[anchorIndex];
let searchCount = searchCountFirst - 1; const currentPointName = anchorEntry?.station || "";
searchCount < points.length; setCurrentPointData(
searchCount++ currentPointName ? getStationDataFromName(currentPointName) : []
) { );
trainList.push(trainDataWidhThrough[searchCount]);
} const nextStopSearchStart = shouldShowCurrentPoint
if (additionalSkipCount > 0) { ? anchorIndex + 1
trainList = trainList.slice(additionalSkipCount); : anchorIndex;
setProbably(true); const nextStopEntry = parsedTrainPath.find(
} else { (entry, index) =>
setProbably(false); index >= nextStopSearchStart && !!entry?.station && !entry.isThrough
} );
const nextStopName = nextStopEntry?.station || "";
setNextStopStationData(
nextStopName ? getStationDataFromName(nextStopName) : []
);
const visibleStart = Math.max(anchorIndex - 1, 0);
const trainList = parsedTrainPath
.slice(visibleStart)
.map((entry) => entry.raw)
.filter((entry) => !!entry);
setProbably(usedTimeEstimation);
setIsCurrentPointDisplay(shouldShowCurrentPoint);
setCurrentDisplayIndex(Math.max(anchorIndex - visibleStart, 0));
setUntilStationData(trainList); setUntilStationData(trainList);
}, [currentPosition, trainDataWidhThrough]); }, [
currentPosition,
parsedTrainPath,
playbackCurrentTimeIso,
stopStationIDList,
tick,
train?.delay,
getStationDataFromName,
]);
const [ToData, setToData] = useState(""); const [ToData, setToData] = useState("");
useEffect(() => { useEffect(() => {
if (customData.to_data && customData.to_data != "") { if (customData.to_data && customData.to_data != "") {
@@ -335,7 +455,9 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
setStation(data); setStation(data);
}, [ToData]); }, [ToData]);
const lineColor = const lineColor =
station.length > 0 customData.to_data_color && customData.to_data_color.length > 0
? customData.to_data_color[0]
: station.length > 0
? lineColorList[station[0]?.StationNumber?.slice(0, 1)] ? lineColorList[station[0]?.StationNumber?.slice(0, 1)]
: "black"; : "black";
//const lineColor = "red"; //const lineColor = "red";
@@ -355,7 +477,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
(last: number, d: string, i: number) => (d ? i : last), -1 (last: number, d: string, i: number) => (d ? i : last), -1
); );
const filteredTrainData = trainDataWidhThrough.filter((d, idx) => { const filteredTrainData = trainDataWidhThrough.filter((d, idx) => {
if (!d) return false; if (!d || isHiddenThroughPointEntry(d)) return false;
const [, se] = d.split(","); const [, se] = d.split(",");
if (!se) return true; if (!se) return true;
// 着を含み発を含まないエントリは終着駅のみ許可 // 着を含み発を含まないエントリは終着駅のみ許可
@@ -407,13 +529,22 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
}; };
}); });
const shouldShowCurrentLabel =
isCurrentPointDisplay || currentPointData[0]?.Station_JP === train?.Pos;
const currentStationLabel = shouldShowCurrentLabel
? currentPointData[0]?.Station_JP || train?.Pos || ""
: train?.Pos || "";
const bannerStationData = shouldShowCurrentLabel
? currentPointData
: nextStopStationData;
// 全駅リスト中の現在地インデックス // 全駅リスト中の現在地インデックス
const currentStationIndex = (() => { const currentStationIndex = (() => {
const pos = train?.Pos || ""; const pos = currentStationLabel;
if (!pos) return 0; if (!pos) return 0;
// Pos は "駅名" (駅にいる時) or "駅A~駅B" (走行中) の形式 // Pos は "駅名" (駅にいる時) or "駅A~駅B" (走行中) の形式
const posStations = pos.split("").map((s: string) => const posStations = pos.split("").map((s: string) =>
s.replace(/(下り)|(上り)|\(下り\)|\(上り\)/g, "").trim() normalizeTrainPosLabel(s)
); );
// 完全一致 // 完全一致
const firstIdx = allStations.findIndex((s) => s.name === posStations[0]); const firstIdx = allStations.findIndex((s) => s.name === posStations[0]);
@@ -427,7 +558,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
})(); })();
const nextStationIndex = (() => { const nextStationIndex = (() => {
const name = nextStationData[0]?.Station_JP; const name = nextStopStationData[0]?.Station_JP;
if (!name) return -1; if (!name) return -1;
const idx = stationStops.indexOf(name); const idx = stationStops.indexOf(name);
if (idx >= 0) return idx; if (idx >= 0) return idx;
@@ -452,11 +583,10 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
if (!liveNotifyId || !train) return; if (!liveNotifyId || !train) return;
const delayNum = train.delay === "入線" ? 0 : parseInt(String(train.delay)) || 0; const delayNum = train.delay === "入線" ? 0 : parseInt(String(train.delay)) || 0;
const delayStatus = delayNum > 0 ? `${delayNum}分遅れ` : "定刻"; const delayStatus = delayNum > 0 ? `${delayNum}分遅れ` : "定刻";
const positionStatus = const positionStatus = shouldShowCurrentLabel ? "ただいま" : "次は";
nextStationData[0]?.Station_JP === train.Pos ? "ただいま" : "次は";
updateTrainFollowActivity(liveNotifyId, { updateTrainFollowActivity(liveNotifyId, {
currentStation: train.Pos || "", currentStation: currentStationLabel,
nextStation: nextStationData[0]?.Station_JP || "", nextStation: nextStopStationData[0]?.Station_JP || "",
delayMinutes: delayNum, delayMinutes: delayNum,
scheduledArrival: "", scheduledArrival: "",
trainNumber: trainID, trainNumber: trainID,
@@ -472,13 +602,27 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
allStations, allStations,
currentStationIndex, currentStationIndex,
}).catch(() => {}); }).catch(() => {});
}, [train, nextStationData, liveNotifyId, stationStops, nextStationIndex, currentStationIndex]); }, [
train,
nextStopStationData,
liveNotifyId,
stationStops,
nextStationIndex,
currentStationIndex,
currentStationLabel,
shouldShowCurrentLabel,
]);
// バナー表示と同時にLive Activityを自動開始 // バナー表示と同時にLive Activityを自動開始
// iOSのみ一時的に無効化中(Androidは有効)
useEffect(() => { useEffect(() => {
// iOSのLive Activityは無効化 if (
if (Platform.OS === 'ios') return; !isLiveActivityAvailable() ||
hasStartedRef.current ||
!train ||
!nextStopStationData[0]
) {
return;
}
hasStartedRef.current = true; hasStartedRef.current = true;
const startActivity = async () => { const startActivity = async () => {
if (Platform.OS === 'android' && Platform.Version >= 33) { if (Platform.OS === 'android' && Platform.Version >= 33) {
@@ -489,15 +633,14 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
} }
const delayNum = train?.delay === "入線" ? 0 : parseInt(String(train?.delay)) || 0; const delayNum = train?.delay === "入線" ? 0 : parseInt(String(train?.delay)) || 0;
const delayStatus = delayNum > 0 ? `${delayNum}分遅れ` : "定刻"; const delayStatus = delayNum > 0 ? `${delayNum}分遅れ` : "定刻";
const positionStatus = const positionStatus = shouldShowCurrentLabel ? "ただいま" : "次は";
nextStationData[0]?.Station_JP === train?.Pos ? "ただいま" : "次は";
try { try {
const id = await startTrainFollowActivity({ const id = await startTrainFollowActivity({
trainNumber: trainID, trainNumber: trainID,
lineName: "", lineName: "",
destination: ToData, destination: ToData,
currentStation: train?.Pos || "", currentStation: currentStationLabel,
nextStation: nextStationData[0]?.Station_JP || "", nextStation: nextStopStationData[0]?.Station_JP || "",
delayMinutes: delayNum, delayMinutes: delayNum,
scheduledArrival: "", scheduledArrival: "",
trainType: customTrainType.shortName, trainType: customTrainType.shortName,
@@ -514,10 +657,175 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
setLiveNotifyId(id); setLiveNotifyId(id);
} catch (e) { } catch (e) {
console.warn('[LiveNotify] start error:', e); console.warn('[LiveNotify] start error:', e);
hasStartedRef.current = false;
} }
}; };
startActivity(); startActivity();
}, [train]); }, [
train,
nextStopStationData,
currentStationLabel,
shouldShowCurrentLabel,
ToData,
trainID,
customTrainType.shortName,
trainNameText,
customTrainType.color,
lineColor,
stationStops,
nextStationIndex,
allStations,
currentStationIndex,
]);
// iOSへ残り停車駅の位置トリガーとりっかちゃん通知音を登録する。
useEffect(() => {
if (Platform.OS !== "ios" || playbackCurrentTimeIso) return;
const upcomingStations = allStations
.slice(currentStationIndex + 1)
.map((entry, offset) => ({ entry, offset }))
.filter(({ entry }) => entry.isStop)
.map(({ entry, offset }) => {
const stationData = getStationDataFromName(entry.name)[0];
if (
!stationData ||
!Number.isFinite(stationData.lat) ||
!Number.isFinite(stationData.lng)
) {
return null;
}
return {
identifier: `${currentStationIndex + 1 + offset}-${stationData.StationNumber || entry.name}`,
stationName: entry.name,
latitude: stationData.lat,
longitude: stationData.lng,
};
})
.filter((station): station is NonNullable<typeof station> => station != null)
.slice(0, 20);
if (upcomingStations.length === 0) return;
const signature = [
backgroundRikkaTrackingId,
currentStationIndex,
...upcomingStations.map((station) => station.identifier),
].join(":");
if (backgroundRikkaSignatureRef.current === signature) return;
const controller = new AbortController();
Promise.all([
AS.getItem(STORAGE_KEYS.BACKGROUND_RIKKA_ANNOUNCEMENT).catch(() => "false"),
AS.getItem(STORAGE_KEYS.BACKGROUND_RIKKA_TRIGGER_SOURCE).catch(
() => DEFAULT_BACKGROUND_RIKKA_TRIGGER_SOURCE
),
])
.then(async ([value, source]) => {
const enabled = value === true || value === "true";
if (
!enabled ||
source === "trainPosition" ||
controller.signal.aborted
) {
backgroundRikkaSignatureRef.current = "";
await cancelLocationAnnouncements(backgroundRikkaTrackingId);
return;
}
backgroundRikkaSignatureRef.current = signature;
const result = await prepareBackgroundRikkaAnnouncements({
trackingId: backgroundRikkaTrackingId,
stations: upcomingStations,
signal: controller.signal,
});
console.info(
`[BackgroundRikka] scheduled=${result.scheduled} failed=${result.failedStations.length}`
);
})
.catch((error) => {
backgroundRikkaSignatureRef.current = "";
if (!controller.signal.aborted) {
console.warn("[BackgroundRikka] Failed to schedule announcements", error);
}
});
return () => controller.abort();
}, [
allStations,
backgroundRikkaTrackingId,
currentStationIndex,
getStationDataFromName,
playbackCurrentTimeIso,
]);
// 検証用: 列車走行位置から算出した次駅が変わった瞬間に通知する。
useEffect(() => {
if (Platform.OS !== "ios") return;
const stationName = nextStopStationData[0]?.Station_JP;
if (!stationName) return;
const controller = new AbortController();
Promise.all([
AS.getItem(STORAGE_KEYS.BACKGROUND_RIKKA_ANNOUNCEMENT).catch(() => "false"),
AS.getItem(STORAGE_KEYS.BACKGROUND_RIKKA_TRIGGER_SOURCE).catch(
() => DEFAULT_BACKGROUND_RIKKA_TRIGGER_SOURCE
),
])
.then(async ([enabledValue, source]) => {
const enabled =
enabledValue === true || enabledValue === "true";
if (
!enabled ||
source !== "trainPosition" ||
controller.signal.aborted
) {
lastTrainPositionAnnouncementRef.current = "";
return;
}
const announcementKey = `${trainID}:${stationName}`;
if (
lastTrainPositionAnnouncementRef.current === announcementKey
) {
return;
}
lastTrainPositionAnnouncementRef.current = announcementKey;
await cancelLocationAnnouncements(backgroundRikkaTrackingId);
if (controller.signal.aborted) return;
await sendTrainPositionRikkaAnnouncement({
stationName,
trainId: trainID,
signal: controller.signal,
});
})
.catch((error) => {
lastTrainPositionAnnouncementRef.current = "";
if (!controller.signal.aborted) {
console.warn(
"[BackgroundRikka] Train-position announcement failed",
error
);
}
});
return () => controller.abort();
}, [
backgroundRikkaTrackingId,
nextStopStationData,
train?.Pos,
trainID,
]);
useEffect(() => {
return () => {
cancelLocationAnnouncements(backgroundRikkaTrackingId).catch(() => {});
};
}, [backgroundRikkaTrackingId]);
return ( return (
<View <View
@@ -576,19 +884,18 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
<Text <Text
style={{ style={{
fontSize: trainNameText.length > 4 ? 12 : 14, fontSize: trainNameText.length > 4 ? 12 : 14,
fontFamily: customTrainType.fontAvailable fontFamily: customTrainType.fontFamily,
? "JR-Nishi" fontWeight: customTrainType.fontFamily ? undefined : "bold",
: undefined, marginTop: customTrainType.fontFamily ? 3 : 0,
fontWeight: !customTrainType.fontAvailable
? "bold"
: undefined,
marginTop: customTrainType.fontAvailable ? 3 : 0,
color: fixed.textOnPrimary, color: fixed.textOnPrimary,
textAlignVertical: "center", textAlignVertical: "center",
textAlign: "left", textAlign: "left",
}} }}
> >
{customTrainType.shortName} {customTrainType.shortName}
{customTrainType.fontFamily === "JR-WEST-PLUS" ? (
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
) : null}
</Text> </Text>
{customData.train_name && ( {customData.train_name && (
<Text <Text
@@ -693,9 +1000,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
marginVertical: -1, marginVertical: -1,
}} }}
> >
{nextStationData[0]?.Station_JP == train?.Pos {shouldShowCurrentLabel ? "ただいま" : "次は"}
? "ただいま"
: "次は"}
</Text> </Text>
{probably && ( {probably && (
<Text <Text
@@ -713,7 +1018,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
)} )}
</View> </View>
<StationNumberMaker <StationNumberMaker
currentStation={nextStationData} currentStation={bannerStationData}
singleSize={20} singleSize={20}
useEach={true} useEach={true}
/> />
@@ -725,7 +1030,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
flex: 1, flex: 1,
}} }}
> >
{nextStationData[0]?.Station_JP || "不明"} {bannerStationData[0]?.Station_JP || "不明"}
</Text> </Text>
{fixedPositionSize !== 226 && ( {fixedPositionSize !== 226 && (
<View <View
@@ -749,6 +1054,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
train={train} train={train}
lineColor={lineColor} lineColor={lineColor}
trainDataWithThrough={untilStationData} trainDataWithThrough={untilStationData}
currentDisplayIndex={currentDisplayIndex}
isSmall={fixedPositionSize !== 226} isSmall={fixedPositionSize !== 226}
/> />
</View> </View>
@@ -875,6 +1181,7 @@ const CurrentPositionBox = ({
train, train,
lineColor, lineColor,
trainDataWithThrough, trainDataWithThrough,
currentDisplayIndex,
isSmall, isSmall,
}) => { }) => {
const { colors } = useThemeColors(); const { colors } = useThemeColors();
@@ -884,13 +1191,13 @@ const CurrentPositionBox = ({
const { isBetween, Pos: PosData } = trainPosition(train); const { isBetween, Pos: PosData } = trainPosition(train);
if (isBetween === true) { if (isBetween === true) {
const { from, to } = PosData; const { from, to } = PosData;
firstText = from; firstText = normalizeTrainPosLabel(from);
secondText = to; secondText = normalizeTrainPosLabel(to);
marginText = "→"; marginText = "→";
} else { } else {
const { Pos } = PosData; const { Pos } = PosData;
if (Pos !== "") { if (Pos !== "") {
firstText = Pos; firstText = normalizeTrainPosLabel(Pos);
} }
} }
const delayTime = train?.delay == "入線" ? 0 : parseInt(train?.delay); const delayTime = train?.delay == "入線" ? 0 : parseInt(train?.delay);
@@ -943,6 +1250,7 @@ const CurrentPositionBox = ({
(() => { (() => {
// 着→発ペアを同一駅で統合(EachStopListと同様) // 着→発ペアを同一駅で統合(EachStopListと同様)
const merged: { d: string; arrivalTime: string | null }[] = []; const merged: { d: string; arrivalTime: string | null }[] = [];
let mergedCurrentDisplayIndex = Math.max(currentDisplayIndex, 0);
for (let i = 0; i < trainDataWithThrough.length; i++) { for (let i = 0; i < trainDataWithThrough.length; i++) {
const d = trainDataWithThrough[i]; const d = trainDataWithThrough[i];
if (!d) continue; if (!d) continue;
@@ -963,11 +1271,17 @@ const CurrentPositionBox = ({
const [prevSt, prevSe, prevTime] = prev.split(","); const [prevSt, prevSe, prevTime] = prev.split(",");
if (prevSt === st && prevSe?.includes("着")) { if (prevSt === st && prevSe?.includes("着")) {
merged.push({ d, arrivalTime: prevTime }); merged.push({ d, arrivalTime: prevTime });
if (i === currentDisplayIndex || i - 1 === currentDisplayIndex) {
mergedCurrentDisplayIndex = merged.length - 1;
}
continue; continue;
} }
} }
} }
merged.push({ d, arrivalTime: null }); merged.push({ d, arrivalTime: null });
if (i === currentDisplayIndex) {
mergedCurrentDisplayIndex = merged.length - 1;
}
} }
return merged.map(({ d, arrivalTime }, index) => ( return merged.map(({ d, arrivalTime }, index) => (
<EachStopData <EachStopData
@@ -977,6 +1291,7 @@ const CurrentPositionBox = ({
delayTime={delayTime} delayTime={delayTime}
isSmall={isSmall} isSmall={isSmall}
secondText={secondText} secondText={secondText}
currentDisplayIndex={mergedCurrentDisplayIndex}
arrivalTime={arrivalTime} arrivalTime={arrivalTime}
/> />
)); ));
@@ -992,18 +1307,28 @@ type eachStopType = {
isSmall: boolean; isSmall: boolean;
index: number; index: number;
secondText: string; secondText: string;
currentDisplayIndex: number;
arrivalTime?: string | null; arrivalTime?: string | null;
}; };
const EachStopData: FC<eachStopType> = (props) => { const EachStopData: FC<eachStopType> = (props) => {
const { colors } = useThemeColors(); const { colors } = useThemeColors();
const { d, delayTime, isSmall, index, secondText, arrivalTime } = props; const { playbackCurrentTimeIso } = useTrainMenu();
const {
d,
delayTime,
isSmall,
index,
secondText,
currentDisplayIndex,
arrivalTime,
} = props;
if (!d) return null; if (!d) return null;
if (d == "") return null; if (d == "") return null;
const [station, se, time] = d.split(","); const [station, se, time] = d.split(",");
const calcMinute = (t: string) => { const calcMinute = (t: string) => {
if (!t || t === "") return null; if (!t || t === "") return null;
const now = dayjs(); const now = playbackCurrentTimeIso ? dayjs(playbackCurrentTimeIso) : dayjs();
const hour = parseInt(t.split(":")[0]); const hour = parseInt(t.split(":")[0]);
const dt = now const dt = now
.hour(hour < 4 ? hour + 24 : hour) .hour(hour < 4 ? hour + 24 : hour)
@@ -1078,7 +1403,7 @@ const EachStopData: FC<eachStopType> = (props) => {
style={{ style={{
fontSize: isSmall ? 8 : 14, fontSize: isSmall ? 8 : 14,
color: color:
index == 1 && secondText == "" index === currentDisplayIndex && secondText === ""
? "#ffe852ff" ? "#ffe852ff"
: se.includes("通") : se.includes("通")
? "#020202ff" ? "#020202ff"
@@ -1088,14 +1413,14 @@ const EachStopData: FC<eachStopType> = (props) => {
fontWeight: "bold", fontWeight: "bold",
}} }}
> >
{index == 1 && secondText == "" {index === currentDisplayIndex && secondText === ""
? "→" ? "→"
: se.includes("通") : se.includes("通")
? null ? null
: "●"} : "●"}
</Text> </Text>
</View> </View>
{index == 0 && secondText != "" && ( {index === 0 && secondText !== "" && (
<View <View
style={{ style={{
flexDirection: "column", flexDirection: "column",
@@ -1,6 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram"; import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
import { useStationList } from "@/stateBox/useStationList"; import { useStationList } from "@/stateBox/useStationList";
import { OriginalStationList } from "@/lib/CommonTypes";
interface StationIDPair { interface StationIDPair {
[key: string]: string; [key: string]: string;
@@ -10,12 +11,10 @@ interface LineListPair {
[key: string]: string; [key: string]: string;
} }
interface OriginalStationList { const isHiddenThroughPointEntry = (raw: string) => {
[key: string]: Array<{ const [station = "", se = ""] = (raw || "").split(",");
Station_JP: string; return station.startsWith(".") && se.includes("通");
StationNumber: string; };
}>;
}
/** /**
* 通過駅を含む列車データを生成するカスタムフック * 通過駅を含む列車データを生成するカスタムフック
@@ -33,8 +32,11 @@ export const useTrainDataWithThrough = (
const [trainDataWithThrough, setTrainDataWithThrough] = useState<string[]>([]); const [trainDataWithThrough, setTrainDataWithThrough] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
const trainData = allTrainDiagram[trainID]?.split("#"); const trainData =
if (!trainData) return; allTrainDiagram[trainID]
?.split("#")
.filter((entry) => !isHiddenThroughPointEntry(entry)) ?? [];
if (trainData.length === 0) return;
// 各停車駅の情報を取得 // 各停車駅の情報を取得
const stopStationList = trainData.map((i) => { const stopStationList = trainData.map((i) => {
@@ -76,6 +78,8 @@ export const useTrainDataWithThrough = (
if (!lineStations) return []; if (!lineStations) return [];
lineStations.forEach((d) => { lineStations.forEach((d) => {
const isHiddenPassPoint = d.Station_JP?.startsWith(".");
if (isHiddenPassPoint) return;
// 順方向の判定 // 順方向の判定
if ( if (
d.StationNumber > baseStationNumberFirst && d.StationNumber > baseStationNumberFirst &&
+222 -18
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from "react"; import React, { useCallback, useEffect, useRef } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { WebView } from "react-native-webview"; import { WebView } from "react-native-webview";
@@ -15,15 +15,19 @@ import { useCurrentTrain } from "../../stateBox/useCurrentTrain";
import { useDeviceOrientationChange } from "../../stateBox/useDeviceOrientationChange"; import { useDeviceOrientationChange } from "../../stateBox/useDeviceOrientationChange";
import { SheetManager } from "react-native-actions-sheet"; import { SheetManager } from "react-native-actions-sheet";
import { useNavigation } from "@react-navigation/native"; import { useNavigation, useIsFocused } from "@react-navigation/native";
import { useTrainMenu } from "../../stateBox/useTrainMenu"; import { useTrainMenu } from "../../stateBox/useTrainMenu";
import { useStationList } from "../../stateBox/useStationList"; import { useStationList } from "../../stateBox/useStationList";
import { useThemeColors } from "@/lib/theme"; import { useThemeColors } from "@/lib/theme";
import { useWebViewRemount } from "@/lib/useWebViewRemount"; import { useWebViewRemount } from "@/lib/useWebViewRemount";
import { generateMockUpdateScript } from "../../lib/mockApi/webviewXhrInterceptor"; import { generateMockUpdateScript } from "../../lib/mockApi/webviewXhrInterceptor";
export const AppsWebView = ({ openStationACFromEachTrainInfo }) => { import * as Sentry from "@sentry/react-native";
import { setAppLifecycleWebViewActive } from "@/lib/observability/appLifecycleCrashSentinel";
export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady }) => {
const initialPositionsUrl = "https://train.jr-shikoku.co.jp/sp.html";
const { webview, currentTrain } = useCurrentTrain(); const { webview, currentTrain } = useCurrentTrain();
const { navigate } = useNavigation<any>(); const { navigate } = useNavigation<any>();
const isFocused = useIsFocused();
const { favoriteStation } = useFavoriteStation(); const { favoriteStation } = useFavoriteStation();
const { isLandscape } = useDeviceOrientationChange(); const { isLandscape } = useDeviceOrientationChange();
const { isDark } = useThemeColors(); const { isDark } = useThemeColors();
@@ -40,27 +44,138 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
mockApiFeatureEnabled, mockApiFeatureEnabled,
mockTrainPositions, mockTrainPositions,
} = useTrainMenu(); } = useTrainMenu();
const { remountKey, remount, processHandlers } = useWebViewRemount(); const addWebViewBreadcrumb = (
message: string,
data?: Record<string, string | number | boolean | null | undefined>
) => {
Sentry.addBreadcrumb({
category: "positions.webview",
level: "info",
message,
data,
});
};
const { remountKey, remount, processHandlers, pingHandlers, webViewRef } = useWebViewRemount({
pingEnabled: Platform.OS === "ios",
backgroundThresholdMs: null,
isFocused,
pauseWatchdogWhenUnfocused: Platform.OS === "ios",
ignoreProcessTerminationWhenUnfocused: Platform.OS === "ios",
onRemount: (reason, data) => {
addWebViewBreadcrumb("webview remount requested", {
reason,
...(data ?? {}),
});
},
});
const lastRemountKeyRef = useRef<typeof remountKey | null>(null);
useEffect(() => {
setAppLifecycleWebViewActive("positions_main", true);
return () => setAppLifecycleWebViewActive("positions_main", false);
}, []);
// コマが変化したとき(再生・シーク)に WebView 内の _MOCK_TRAIN を差し替えて再描画 // コマが変化したとき(再生・シーク)に WebView 内の _MOCK_TRAIN を差し替えて再描画
const mountedRef = useRef(false); const mountedRef = useRef(false);
const urlCacheRef = useRef("");
const initialInjectDoneRef = useRef(false);
const pendingInitialInjectRef = useRef<string | null>(null);
const loadEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const focusedRef = useRef(isFocused);
const initialLoadReadyNotifiedRef = useRef(false);
const previousMockApiFeatureEnabledRef = useRef(mockApiFeatureEnabled);
useEffect(() => {
const previousEnabled = previousMockApiFeatureEnabledRef.current;
if (previousEnabled === mockApiFeatureEnabled) return;
previousMockApiFeatureEnabledRef.current = mockApiFeatureEnabled;
addWebViewBreadcrumb("mock mode changed; remounting webview", {
previousEnabled,
enabled: mockApiFeatureEnabled,
focused: focusedRef.current,
});
remount();
}, [mockApiFeatureEnabled, remount]);
useEffect(() => {
focusedRef.current = isFocused;
addWebViewBreadcrumb(isFocused ? "webview focused" : "webview blurred", {
landscape: isLandscape,
mockApi: mockApiFeatureEnabled,
});
if (!isFocused) return;
if (!pendingInitialInjectRef.current) return;
addWebViewBreadcrumb("webview pending initial inject resumed on focus");
webview?.current?.injectJavaScript(pendingInitialInjectRef.current);
pendingInitialInjectRef.current = null;
initialInjectDoneRef.current = true;
}, [isFocused, isLandscape, mockApiFeatureEnabled]);
useEffect(() => {
if (lastRemountKeyRef.current !== remountKey) {
addWebViewBreadcrumb("webview remount key", { remountKey });
lastRemountKeyRef.current = remountKey;
initialInjectDoneRef.current = false;
pendingInitialInjectRef.current = null;
initialLoadReadyNotifiedRef.current = false;
if (loadEndTimeoutRef.current) {
clearTimeout(loadEndTimeoutRef.current);
loadEndTimeoutRef.current = null;
}
}
}, [remountKey]);
useEffect(() => {
Sentry.setContext("positions_webview", {
focused: isFocused,
landscape: isLandscape,
mockApi: mockApiFeatureEnabled,
remountKey,
currentUrl: urlCacheRef.current || null,
});
}, [isFocused, isLandscape, mockApiFeatureEnabled, remountKey]);
useEffect(() => {
return () => {
addWebViewBreadcrumb("webview cleanup start", {
focused: focusedRef.current,
currentUrl: urlCacheRef.current || null,
});
if (loadEndTimeoutRef.current) {
clearTimeout(loadEndTimeoutRef.current);
loadEndTimeoutRef.current = null;
}
addWebViewBreadcrumb("webview unmounted");
};
}, []);
useEffect(() => { useEffect(() => {
// 初回マウント時はスキップ(beforeContentLoaded で既に正しいデータが入っている) // 初回マウント時はスキップ(beforeContentLoaded で既に正しいデータが入っている)
if (!mountedRef.current) { if (!mountedRef.current) {
mountedRef.current = true; mountedRef.current = true;
addWebViewBreadcrumb("mock positions effect skipped initial mount", {
mockApi: mockApiFeatureEnabled,
});
return; return;
} }
if (!mockApiFeatureEnabled || !mockTrainPositions) return; if (!mockApiFeatureEnabled || !mockTrainPositions) return;
addWebViewBreadcrumb("mock positions injected", {
positionCount: mockTrainPositions.length,
});
const script = generateMockUpdateScript(mockTrainPositions); const script = generateMockUpdateScript(mockTrainPositions);
webview?.current?.injectJavaScript(script); webview?.current?.injectJavaScript(script);
}, [mockTrainPositions]); }, [mockApiFeatureEnabled, mockTrainPositions, webview]);
var urlcache = "";
let once = false; const attachWebViewRefs = useCallback((instance) => {
webview.current = instance;
webViewRef.current = instance;
}, [webViewRef, webview]);
const onNavigationStateChange = ({ url }) => { const onNavigationStateChange = ({ url }) => {
if (url == urlcache) return; if (url == urlCacheRef.current) return;
//URL二重判定回避 //URL二重判定回避
urlcache = url; urlCacheRef.current = url;
addWebViewBreadcrumb("webview navigation", { url });
switch (true) { switch (true) {
case url.includes("https://train.jr-shikoku.co.jp/usage.htm"): case url.includes("https://train.jr-shikoku.co.jp/usage.htm"):
if (Platform.OS === "android") navigate("howto", { info: url }); if (Platform.OS === "android") navigate("howto", { info: url });
@@ -74,7 +189,20 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
break; break;
} }
}; };
const getRestorableUrl = () => {
const cachedUrl = urlCacheRef.current;
if (!cachedUrl) return initialPositionsUrl;
if (cachedUrl.includes("https://train.jr-shikoku.co.jp/usage.htm")) {
return initialPositionsUrl;
}
if (cachedUrl.includes("https://train.jr-shikoku.co.jp/train.html")) {
return initialPositionsUrl;
}
return cachedUrl;
};
const onMessage = (event) => { const onMessage = (event) => {
pingHandlers.onMessage?.(event);
const { data } = event.nativeEvent; const { data } = event.nativeEvent;
/** /**
* {type,trainNum,limited} * {type,trainNum,limited}
@@ -82,14 +210,27 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
* {type,event,id,name,pdf,map,url,chk} * {type,event,id,name,pdf,map,url,chk}
*/ */
if (data.includes("train.html")) { if (data.includes("train.html")) {
addWebViewBreadcrumb("webview train link", { data });
navigate("trainbase", { info: data, from: "Train" }); navigate("trainbase", { info: data, from: "Train" });
return; return;
} }
if (!originalStationList) { if (!originalStationList) {
addWebViewBreadcrumb("webview message blocked waiting station data");
alert("駅名標データを取得中..."); alert("駅名標データを取得中...");
return; return;
} }
const dataSet = JSON.parse(data); let dataSet;
try {
dataSet = JSON.parse(data);
} catch (error) {
addWebViewBreadcrumb("webview message parse failed", {
dataPreview: data.slice(0, 120),
});
throw error;
}
addWebViewBreadcrumb("webview message", {
type: dataSet.type ?? "unknown",
});
switch (dataSet.type) { switch (dataSet.type) {
case "LoadError": { case "LoadError": {
setLoadError(true); setLoadError(true);
@@ -153,8 +294,30 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
} }
}; };
const onLoadStart = () => {
pingHandlers.onLoadStart?.();
addWebViewBreadcrumb("webview loadStart", {
focused: focusedRef.current,
currentUrl: urlCacheRef.current || null,
});
};
const onLoadEnd = () => { const onLoadEnd = () => {
if (once) return () => {}; pingHandlers.onLoadEnd?.();
addWebViewBreadcrumb("webview loadEnd", {
focused: focusedRef.current,
hasStationData: !!stationData,
hasOriginalStationList: !!originalStationList,
favoriteCount: favoriteStation.length,
});
if (!initialLoadReadyNotifiedRef.current) {
initialLoadReadyNotifiedRef.current = true;
addWebViewBreadcrumb("webview initial load ready", {
focused: focusedRef.current,
});
onInitialLoadReady?.();
}
if (initialInjectDoneRef.current) return () => {};
if (!stationData) return () => {}; if (!stationData) return () => {};
if (!originalStationList) return () => {}; if (!originalStationList) return () => {};
if (favoriteStation.length < 1) return () => {}; if (favoriteStation.length < 1) return () => {};
@@ -162,17 +325,45 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
favoriteStation[0][0].StationNumber favoriteStation[0][0].StationNumber
); );
if (!string) return () => {}; if (!string) return () => {};
setTimeout(() => { if (loadEndTimeoutRef.current) {
clearTimeout(loadEndTimeoutRef.current);
}
addWebViewBreadcrumb("webview initial inject scheduled", {
focused: focusedRef.current,
});
loadEndTimeoutRef.current = setTimeout(() => {
addWebViewBreadcrumb(
focusedRef.current
? "webview initial inject run"
: "webview initial inject run while blurred",
{
focused: focusedRef.current,
}
);
webview?.current?.injectJavaScript(string); webview?.current?.injectJavaScript(string);
}, 500); pendingInitialInjectRef.current = null;
once = true; initialInjectDoneRef.current = true;
loadEndTimeoutRef.current = null;
}, focusedRef.current ? 500 : 700);
};
const handleRenderProcessGone = (event) => {
addWebViewBreadcrumb("webview render process gone", {
didCrash: event.nativeEvent.didCrash,
});
processHandlers.onRenderProcessGone?.(event);
};
const handleContentProcessDidTerminate = () => {
addWebViewBreadcrumb("webview content process terminated");
processHandlers.onContentProcessDidTerminate?.();
}; };
return ( return (
<WebView <WebView
key={(isDark ? 'dark' : 'light') + (mockApiFeatureEnabled ? '-mock' : '-live') + '-' + remountKey} key={`positions-webview-${remountKey}`}
ref={webview} ref={attachWebViewRefs}
source={{ uri: "https://train.jr-shikoku.co.jp/sp.html" }} source={{ uri: getRestorableUrl() }}
originWhitelist={[ originWhitelist={[
"https://train.jr-shikoku.co.jp", "https://train.jr-shikoku.co.jp",
"https://train.jr-shikoku.co.jp/sp.html", "https://train.jr-shikoku.co.jp/sp.html",
@@ -183,8 +374,21 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
setSupportMultipleWindows setSupportMultipleWindows
contentMode="mobile" contentMode="mobile"
style={{ backgroundColor: bgColor }} style={{ backgroundColor: bgColor }}
onLoadStart={onLoadStart}
onError={(event) => {
addWebViewBreadcrumb("webview error", {
description: event.nativeEvent.description || null,
code: event.nativeEvent.code ?? null,
});
}}
onHttpError={(event) => {
addWebViewBreadcrumb("webview http error", {
statusCode: event.nativeEvent.statusCode ?? null,
});
}}
onRenderProcessGone={handleRenderProcessGone}
onContentProcessDidTerminate={handleContentProcessDidTerminate}
{...{ onMessage, onNavigationStateChange, onLoadEnd }} {...{ onMessage, onNavigationStateChange, onLoadEnd }}
{...processHandlers}
injectedJavaScriptBeforeContentLoaded={injectJavascriptBeforeContentLoaded} injectedJavaScriptBeforeContentLoaded={injectJavascriptBeforeContentLoaded}
injectedJavaScript={injectJavascript} injectedJavaScript={injectJavascript}
/> />
+100 -22
View File
@@ -9,6 +9,7 @@ import {
TouchableOpacity, TouchableOpacity,
Text, Text,
ScrollView, ScrollView,
Platform,
} from "react-native"; } from "react-native";
import Carousel, { ICarouselInstance } from "react-native-reanimated-carousel"; import Carousel, { ICarouselInstance } from "react-native-reanimated-carousel";
import { SheetManager } from "react-native-actions-sheet"; import { SheetManager } from "react-native-actions-sheet";
@@ -27,6 +28,10 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useSortMode } from "./useSortMode"; import { useSortMode } from "./useSortMode";
import { StationSource } from "@/types"; import { StationSource } from "@/types";
import * as Sentry from "@sentry/react-native";
const isMissingStorageKeyError = (error: unknown) =>
String(error).includes("Not Found!");
export const CarouselBox = ({ export const CarouselBox = ({
originalStationList, originalStationList,
@@ -45,6 +50,9 @@ export const CarouselBox = ({
navigate: any; navigate: any;
stationSource: StationSource; stationSource: StationSource;
}) => { }) => {
const isAndroid = Platform.OS === "android";
const sortControlsEntering = isAndroid ? undefined : FadeIn.duration(200);
const sortControlsExiting = isAndroid ? undefined : FadeOut.duration(150);
const carouselRef = useRef<ICarouselInstance>(null); const carouselRef = useRef<ICarouselInstance>(null);
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const { colors, fixed } = useThemeColors(); const { colors, fixed } = useThemeColors();
@@ -109,6 +117,15 @@ export const CarouselBox = ({
// コンテナ高さ(カルーセル ↔ グリッドで可変) // コンテナ高さ(カルーセル ↔ グリッドで可変)
const containerHeight = useSharedValue(carouselHeight); const containerHeight = useSharedValue(carouselHeight);
const containerHeightStyle = useAnimatedStyle(() => ({ height: containerHeight.value })); const containerHeightStyle = useAnimatedStyle(() => ({ height: containerHeight.value }));
const androidContainerStyle = isAndroid ? { height: isSortMode ? gridHeight : carouselHeight } : null;
const carouselModeProps = {
mode: "parallax" as const,
modeConfig: {
parallaxScrollingScale: 1,
parallaxScrollingOffset: 100,
parallaxAdjacentItemScale: 0.8,
},
};
// ドットエリアのフェード // ドットエリアのフェード
const dotsOpacity = useSharedValue(1); const dotsOpacity = useSharedValue(1);
@@ -122,6 +139,11 @@ export const CarouselBox = ({
const gridAnimStyle = useAnimatedStyle(() => ({ opacity: gridOpacity.value })); const gridAnimStyle = useAnimatedStyle(() => ({ opacity: gridOpacity.value }));
useEffect(() => { useEffect(() => {
if (isAndroid) {
setIsGridMounted(isSortMode);
return;
}
const duration = 250; const duration = 250;
if (isSortMode) { if (isSortMode) {
setIsGridMounted(true); // フェードイン前にマウント setIsGridMounted(true); // フェードイン前にマウント
@@ -137,7 +159,7 @@ export const CarouselBox = ({
if (finished) runOnJS(setIsGridMounted)(false); // フェードアウト完了後にアンマウント if (finished) runOnJS(setIsGridMounted)(false); // フェードアウト完了後にアンマウント
}); });
} }
}, [isSortMode, gridHeight, carouselHeight]); }, [isAndroid, isSortMode, gridHeight, carouselHeight]);
// ソートモード終了直後フラグ(次の listIndex 変更でアニメーションをスキップ) // ソートモード終了直後フラグ(次の listIndex 変更でアニメーションをスキップ)
const justExitedSortRef = useRef(false); const justExitedSortRef = useRef(false);
@@ -150,11 +172,10 @@ export const CarouselBox = ({
// バッジからのインデックス変更をカルーセルに反映 // バッジからのインデックス変更をカルーセルに反映
useEffect(() => { useEffect(() => {
if (listIndex >= 0 && carouselRef.current) { if (listIndex < 0) return;
const animated = !justExitedSortRef.current; const animated = !justExitedSortRef.current;
justExitedSortRef.current = false; justExitedSortRef.current = false;
carouselRef.current.scrollTo({ index: listIndex, animated }); carouselRef.current?.scrollTo({ index: listIndex, animated });
}
}, [listIndex]); }, [listIndex]);
// ドットのスクロール追従 // ドットのスクロール追従
@@ -167,9 +188,39 @@ export const CarouselBox = ({
// ドット表示設定の読み込み // ドット表示設定の読み込み
useEffect(() => { useEffect(() => {
AS.getItem("CarouselSettings/activeDotSettings").then((data) => { Sentry.addBreadcrumb({
setDotButton(data === "true"); category: "menu.carousel",
level: "info",
message: "carousel activeDotSettings read scheduled",
}); });
AS.getItem("CarouselSettings/activeDotSettings")
.then((data) => {
Sentry.addBreadcrumb({
category: "menu.carousel",
level: "info",
message: "carousel activeDotSettings read success",
data: {
rawValue: data == null ? "<null>" : String(data),
resolved: data === "true",
},
});
setDotButton(data === "true");
})
.catch((error) => {
setDotButton(false);
if (isMissingStorageKeyError(error)) {
return;
}
Sentry.addBreadcrumb({
category: "menu.carousel",
level: "error",
message: "carousel activeDotSettings read failed",
data: {
errorName: error instanceof Error ? error.name : typeof error,
errorMessage: error instanceof Error ? error.message : String(error),
},
});
});
}, []); }, []);
const oPSign = () => { const oPSign = () => {
@@ -199,7 +250,38 @@ export const CarouselBox = ({
duration: 600, duration: 600,
update: { type: "spring", springDamping: 0.5 }, update: { type: "spring", springDamping: 0.5 },
}); });
AS.setItem("CarouselSettings/activeDotSettings", !dotButton ? "true" : "false"); const nextValue = !dotButton ? "true" : "false";
Sentry.addBreadcrumb({
category: "menu.carousel",
level: "info",
message: "carousel activeDotSettings write scheduled",
data: {
nextValue,
},
});
void AS.setItem("CarouselSettings/activeDotSettings", nextValue)
.then(() => {
Sentry.addBreadcrumb({
category: "menu.carousel",
level: "info",
message: "carousel activeDotSettings write success",
data: {
nextValue,
},
});
})
.catch((error) => {
Sentry.addBreadcrumb({
category: "menu.carousel",
level: "error",
message: "carousel activeDotSettings write failed",
data: {
nextValue,
errorName: error instanceof Error ? error.name : typeof error,
errorMessage: error instanceof Error ? error.message : String(error),
},
});
});
setDotButton(!dotButton); setDotButton(!dotButton);
}; };
@@ -255,10 +337,10 @@ export const CarouselBox = ({
return ( return (
<View style={{ flex: 1, paddingTop: 10 }}> <View style={{ flex: 1, paddingTop: 10 }}>
{/* カルーセル / グリッド(同じ高さ領域を共用・クロスフェード) */} {/* カルーセル / グリッド(同じ高さ領域を共用・クロスフェード) */}
<Animated.View style={[{ overflow: "visible" }, containerHeightStyle]}> <Animated.View style={[{ overflow: "visible" }, isAndroid ? androidContainerStyle : containerHeightStyle]}>
{/* カルーセル */} {/* カルーセル */}
<Animated.View <Animated.View
style={[{ position: "absolute", width }, carouselAnimStyle]} style={[{ position: "absolute", width }, isAndroid ? { opacity: isSortMode ? 0 : 1 } : carouselAnimStyle]}
pointerEvents={isSortMode ? "none" : "auto"} pointerEvents={isSortMode ? "none" : "auto"}
> >
<Carousel <Carousel
@@ -270,13 +352,7 @@ export const CarouselBox = ({
loop={false} loop={false}
width={width} width={width}
style={{ width, alignContent: "center" }} style={{ width, alignContent: "center" }}
mode="parallax" scrollAnimationDuration={isAndroid ? 450 : 600}
modeConfig={{
parallaxScrollingScale: 1,
parallaxScrollingOffset: 100,
parallaxAdjacentItemScale: 0.8,
}}
scrollAnimationDuration={600}
onSnapToItem={setListIndex} onSnapToItem={setListIndex}
renderItem={RenderItem} renderItem={RenderItem}
overscrollEnabled={false} overscrollEnabled={false}
@@ -285,6 +361,8 @@ export const CarouselBox = ({
? 0 ? 0
: lastValidListIndexRef.current : lastValidListIndexRef.current
} }
enabled={!isSortMode}
{...carouselModeProps}
/> />
</Animated.View> </Animated.View>
@@ -293,7 +371,7 @@ export const CarouselBox = ({
<Animated.View <Animated.View
style={[ style={[
{ position: "absolute", width, height: gridHeight, paddingHorizontal: gridPad, overflow: "visible" }, { position: "absolute", width, height: gridHeight, paddingHorizontal: gridPad, overflow: "visible" },
gridAnimStyle, isAndroid ? { opacity: 1 } : gridAnimStyle,
]} ]}
> >
<Sortable.Grid <Sortable.Grid
@@ -311,7 +389,7 @@ export const CarouselBox = ({
</Animated.View> </Animated.View>
{/* ドットエリア:ソートモード時はフェードアウト */} {/* ドットエリア:ソートモード時はフェードアウト */}
<Animated.View style={dotsAnimStyle} pointerEvents={isSortMode ? "none" : "auto"}> <Animated.View style={isAndroid ? { opacity: isSortMode ? 0 : 1 } : dotsAnimStyle} pointerEvents={isSortMode ? "none" : "auto"}>
<ScrollView <ScrollView
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
@@ -355,8 +433,8 @@ export const CarouselBox = ({
{/* 並び替えコントロール:ソートモード時に最下部からスライドイン */} {/* 並び替えコントロール:ソートモード時に最下部からスライドイン */}
{isSortMode && ( {isSortMode && (
<Animated.View <Animated.View
entering={FadeIn.duration(200)} entering={sortControlsEntering}
exiting={FadeOut.duration(150)} exiting={sortControlsExiting}
style={{ style={{
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
+2 -258
View File
@@ -1,6 +1,5 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect } from "react";
import { import {
Alert,
View, View,
Text, Text,
ScrollView, ScrollView,
@@ -12,7 +11,6 @@ import {
import { Switch } from "@rneui/themed"; import { Switch } from "@rneui/themed";
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from "@react-navigation/native";
import { MaterialCommunityIcons } from "@expo/vector-icons"; import { MaterialCommunityIcons } from "@expo/vector-icons";
import Swipeable from "react-native-gesture-handler/Swipeable";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem"; import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { AS } from "../../storageControl"; import { AS } from "../../storageControl";
import { STORAGE_KEYS } from "@/constants"; import { STORAGE_KEYS } from "@/constants";
@@ -278,19 +276,7 @@ const ELESITE_FEATURES: Feature[] = [
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
export const DataSourceSettings = () => { export const DataSourceSettings = () => {
const navigation = useNavigation(); const navigation = useNavigation();
const { const { updatePermission } = useTrainMenu();
updatePermission,
mockApiFeatureEnabled,
setMockApiFeatureEnabled,
recorderState,
recordingSnapshotCount,
recordingList,
startRecording,
stopRecording,
startPlayback,
stopPlayback,
deleteRecording,
} = useTrainMenu();
const { colors, fixed } = useThemeColors(); const { colors, fixed } = useThemeColors();
const showDebugSelector = __DEV__ || updatePermission; const showDebugSelector = __DEV__ || updatePermission;
const [useUnyohub, setUseUnyohub] = useState(false); const [useUnyohub, setUseUnyohub] = useState(false);
@@ -304,8 +290,6 @@ export const DataSourceSettings = () => {
useState<JrDataSystemUiVariant>( useState<JrDataSystemUiVariant>(
getJrDataSystemUiVariant(DEFAULT_JR_DATA_SYSTEM_ENV), getJrDataSystemUiVariant(DEFAULT_JR_DATA_SYSTEM_ENV),
); );
const recordingSwipeRefs = useRef<Record<string, { close: () => void } | null>>({});
const applyJrDataSystemEnv = (env: JrDataSystemEnvironmentKey) => { const applyJrDataSystemEnv = (env: JrDataSystemEnvironmentKey) => {
setJrDataSystemEnv(env); setJrDataSystemEnv(env);
setJrDataSystemTrack(getJrDataSystemTrack(env)); setJrDataSystemTrack(getJrDataSystemTrack(env));
@@ -361,28 +345,6 @@ export const DataSourceSettings = () => {
applyJrDataSystemEnv(env); applyJrDataSystemEnv(env);
}; };
const closeRecordingSwipe = (id: string) => {
recordingSwipeRefs.current[id]?.close();
};
const confirmDeleteRecording = (id: string, label: string) => {
closeRecordingSwipe(id);
Alert.alert(
"録画を削除",
`${label} の録画を削除しますか?`,
[
{ text: "キャンセル", style: "cancel" },
{
text: "削除",
style: "destructive",
onPress: () => {
void deleteRecording(id);
},
},
],
);
};
return ( return (
<View style={[styles.container, { backgroundColor: fixed.primary }]}> <View style={[styles.container, { backgroundColor: fixed.primary }]}>
<SheetHeaderItem <SheetHeaderItem
@@ -451,224 +413,6 @@ export const DataSourceSettings = () => {
{showDebugSelector && ( {showDebugSelector && (
<> <>
<View
style={[
styles.debugSection,
{
backgroundColor: colors.surface,
borderColor: colors.borderSecondary,
},
]}
>
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>
デバッグ: モックAPI検証
</Text>
<Text
style={[
styles.debugDescription,
{ color: colors.textSecondary },
]}
>
</Text>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginTop: 8,
}}
>
<Text
style={[
styles.debugCurrentText,
{ color: colors.textPrimary, fontSize: 14 },
]}
>
API検証機能
</Text>
<Switch
value={mockApiFeatureEnabled}
onValueChange={setMockApiFeatureEnabled}
color={fixed.primary}
/>
</View>
</View>
{/* 録画・再生 */}
<View
style={[
styles.debugSection,
{ backgroundColor: colors.surface, borderColor: colors.borderSecondary },
]}
>
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>
デバッグ: 走行位置録画
</Text>
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}>
OFFになります
</Text>
{/* ステータス行 */}
<View style={{ flexDirection: "row", alignItems: "center", marginTop: 8, gap: 8 }}>
<View
style={{
width: 10, height: 10, borderRadius: 5,
backgroundColor:
recorderState === 'recording' ? '#e53935' :
recorderState === 'playing' ? '#43a047' : colors.borderSecondary,
}}
/>
<Text style={[styles.debugCurrentText, { color: colors.textSecondary, fontSize: 13 }]}>
{recorderState === 'recording'
? `録画中… ${recordingSnapshotCount} スナップショット`
: recorderState === 'playing'
? '再生中'
: `${recordingList.length} 件の録画`}
</Text>
</View>
{/* 録画開始 / 停止ボタン */}
<View style={{ flexDirection: "row", gap: 8, marginTop: 10, flexWrap: "wrap" }}>
{recorderState === 'idle' && (
<TouchableOpacity
onPress={startRecording}
style={{
backgroundColor: '#e53935', borderRadius: 8,
paddingHorizontal: 14, paddingVertical: 8,
}}
>
<Text style={{ color: '#fff', fontWeight: 'bold', fontSize: 13 }}> </Text>
</TouchableOpacity>
)}
{recorderState === 'recording' && (
<TouchableOpacity
onPress={stopRecording}
style={{
backgroundColor: colors.borderSecondary, borderRadius: 8,
paddingHorizontal: 14, paddingVertical: 8,
}}
>
<Text style={{ color: colors.textPrimary, fontWeight: 'bold', fontSize: 13 }}> </Text>
</TouchableOpacity>
)}
{recorderState === 'playing' && (
<TouchableOpacity
onPress={stopPlayback}
style={{
backgroundColor: colors.borderSecondary, borderRadius: 8,
paddingHorizontal: 14, paddingVertical: 8,
}}
>
<Text style={{ color: colors.textPrimary, fontWeight: 'bold', 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',
});
const recordingRow = (
<TouchableOpacity
onPress={() => startPlayback(rec.id)}
disabled={isPlaying}
activeOpacity={0.72}
style={{
flexDirection: 'row', alignItems: 'center',
backgroundColor: colors.backgroundSecondary,
borderRadius: 8, padding: 12, gap: 10,
opacity: isPlaying ? 0.6 : 1,
}}
>
<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>
<View style={{ alignItems: 'flex-end', gap: 4 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
<MaterialCommunityIcons
name={isPlaying ? 'pause-circle-outline' : 'play-circle-outline'}
size={18}
color={isPlaying ? colors.textTertiary : '#43a047'}
/>
<Text
style={{
color: isPlaying ? colors.textTertiary : colors.textPrimary,
fontSize: 12,
fontWeight: '600',
}}
>
{isPlaying ? '再生中は操作不可' : 'タップで再生'}
</Text>
</View>
{!isPlaying && (
<Text style={{ color: colors.textTertiary, fontSize: 10 }}>
</Text>
)}
</View>
<MaterialCommunityIcons
name="chevron-right"
size={18}
color={colors.iconSecondary}
/>
</TouchableOpacity>
);
if (isPlaying) {
return <View key={rec.id}>{recordingRow}</View>;
}
return (
<Swipeable
key={rec.id}
ref={(instance) => {
recordingSwipeRefs.current[rec.id] = instance;
}}
friction={2}
overshootRight={false}
rightThreshold={48}
renderRightActions={() => (
<View
style={{
width: 96,
borderRadius: 8,
backgroundColor: '#e53935',
alignItems: 'center',
justifyContent: 'center',
marginLeft: 6,
}}
>
<MaterialCommunityIcons name="trash-can-outline" size={18} color="#fff" />
<Text style={{ color: '#fff', fontSize: 11, fontWeight: 'bold', marginTop: 4 }}>
</Text>
</View>
)}
onSwipeableOpen={() => confirmDeleteRecording(rec.id, dateLabel)}
>
{recordingRow}
</Swipeable>
);
})}
</View>
)}
</View>
<View <View
style={[ style={[
styles.debugSection, styles.debugSection,
@@ -0,0 +1,528 @@
import React, { useRef, useState } from "react";
import {
Alert,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { Switch } from "@rneui/themed";
import { useNavigation } from "@react-navigation/native";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import Swipeable from "react-native-gesture-handler/Swipeable";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { useThemeColors } from "@/lib/theme";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { useNotification } from "@/stateBox/useNotifications";
export const ResearchToolsSettings = () => {
const navigation = useNavigation<any>();
const { colors, fixed } = useThemeColors();
const { expoPushToken } = useNotification();
const {
updatePermission,
mockApiFeatureEnabled,
setMockApiFeatureEnabled,
recorderState,
recordingSnapshotCount,
recordingList,
startRecording,
stopRecording,
startPlayback,
stopPlayback,
deleteRecording,
exportRecordingFile,
exportAllRecordingsFile,
importRecordingFile,
} = useTrainMenu();
const showResearchTools = __DEV__ || updatePermission;
const recordingSwipeRefs = useRef<Record<string, { close: () => void } | null>>({});
const [recordingFileStatus, setRecordingFileStatus] = useState<{
type: "info" | "success" | "error";
text: string;
}>({ type: "info", text: "録画JSONの書き出しと読み込みができます。" });
const closeRecordingSwipe = (id: string) => {
recordingSwipeRefs.current[id]?.close();
};
const confirmDeleteRecording = (id: string, label: string) => {
closeRecordingSwipe(id);
Alert.alert("録画を削除", `${label} の録画を削除しますか?`, [
{ text: "キャンセル", style: "cancel" },
{
text: "削除",
style: "destructive",
onPress: () => {
void deleteRecording(id);
},
},
]);
};
const setFileStatus = (type: "info" | "success" | "error", text: string) => {
setRecordingFileStatus({ type, text });
};
const handleExportRecordingFile = async (id: string, label: string) => {
try {
await exportRecordingFile(id);
setFileStatus("success", `${label} の録画JSONを書き出しました。`);
} catch (error) {
setFileStatus(
"error",
`録画JSONを書き出せませんでした: ${(error as Error).message}`,
);
}
};
const handleExportAllRecordingsFile = async () => {
try {
await exportAllRecordingsFile();
setFileStatus("success", `${recordingList.length}件の録画JSONを書き出しました。`);
} catch (error) {
setFileStatus(
"error",
`録画JSONを書き出せませんでした: ${(error as Error).message}`,
);
}
};
const handleImportRecordingFile = async () => {
try {
const result = await importRecordingFile();
if (!result) {
setFileStatus("info", "録画JSONの読み込みをキャンセルしました。");
return;
}
setFileStatus(
"success",
result.overwrittenCount > 0
? `${result.importedCount}件を読み込みました。${result.overwrittenCount}件は同じIDのため上書きしました。`
: `${result.importedCount}件を読み込みました。`,
);
} catch (error) {
setFileStatus(
"error",
`録画JSONを読み込めませんでした: ${(error as Error).message}`,
);
}
};
const openRecordingDatabase = () => {
const uri = `https://experimental.shikoku-railinfo.haruk.in/position-board?from=eachTrainInfo&userID=${encodeURIComponent(expoPushToken || "")}`;
const params = {
uri,
importRecordingDownloads: true,
useExitButton: false,
};
const parentNavigation = navigation.getParent?.();
if (parentNavigation) {
parentNavigation.navigate("generalWebView", params);
return;
}
navigation.navigate("generalWebView", params);
};
return (
<View style={[styles.container, { backgroundColor: fixed.primary }]}>
<SheetHeaderItem
title="調査ツール"
LeftItem={{
title: " 戻る",
onPress: () => navigation.goBack(),
position: "left",
}}
/>
<ScrollView
style={[styles.content, { backgroundColor: colors.backgroundSecondary }]}
contentContainerStyle={styles.contentInner}
>
{!showResearchTools ? (
<View
style={[
styles.debugSection,
{
backgroundColor: colors.surface,
borderColor: colors.borderSecondary,
},
]}
>
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>調</Text>
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}>調</Text>
</View>
) : (
<>
<View
style={[
styles.debugSection,
{
backgroundColor: colors.surface,
borderColor: colors.borderSecondary,
},
]}
>
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>デバッグ: モックAPI検証</Text>
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}></Text>
<View style={styles.switchRow}>
<Text style={[styles.debugCurrentText, { color: colors.textPrimary, fontSize: 14 }]}>API検証機能</Text>
<Switch
value={mockApiFeatureEnabled}
onValueChange={setMockApiFeatureEnabled}
color={fixed.primary}
/>
</View>
</View>
<View
style={[
styles.debugSection,
{
backgroundColor: colors.surface,
borderColor: colors.borderSecondary,
},
]}
>
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>デバッグ: 走行位置録画</Text>
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}>OFFになります</Text>
<View style={styles.statusRow}>
<View
style={{
width: 10,
height: 10,
borderRadius: 5,
backgroundColor:
recorderState === "recording"
? "#e53935"
: recorderState === "playing"
? "#43a047"
: colors.borderSecondary,
}}
/>
<Text style={[styles.debugCurrentText, { color: colors.textSecondary, fontSize: 13 }]}>
{recorderState === "recording"
? `録画中… ${recordingSnapshotCount} スナップショット`
: recorderState === "playing"
? "再生中"
: `${recordingList.length} 件の録画`}
</Text>
</View>
<View style={styles.buttonRow}>
{recorderState === "idle" && (
<TouchableOpacity onPress={startRecording} style={styles.recordButton}>
<Text style={styles.primaryButtonText}> </Text>
</TouchableOpacity>
)}
{recorderState === "recording" && (
<TouchableOpacity onPress={stopRecording} style={[styles.neutralButton, { backgroundColor: colors.borderSecondary }]}>
<Text style={[styles.neutralButtonText, { color: colors.textPrimary }]}> </Text>
</TouchableOpacity>
)}
{recorderState === "playing" && (
<TouchableOpacity onPress={stopPlayback} style={[styles.neutralButton, { backgroundColor: colors.borderSecondary }]}>
<Text style={[styles.neutralButtonText, { color: colors.textPrimary }]}> </Text>
</TouchableOpacity>
)}
</View>
<View style={[styles.fileBox, { backgroundColor: colors.backgroundTertiary }]}>
<Text style={{ color: colors.textPrimary, fontSize: 13, fontWeight: "600" }}>JSONファイル</Text>
<View style={styles.buttonRow}>
<TouchableOpacity
onPress={() => {
void handleImportRecordingFile();
}}
disabled={recorderState === "recording"}
style={{
backgroundColor: recorderState === "recording" ? colors.borderSecondary : fixed.primary,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 8,
opacity: recorderState === "recording" ? 0.6 : 1,
}}
>
<Text style={{ color: fixed.textOnPrimary, fontWeight: "bold", fontSize: 12 }}>JSONを読み込む</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
void handleExportAllRecordingsFile();
}}
disabled={recordingList.length === 0 || recorderState === "recording"}
style={{
backgroundColor:
recordingList.length === 0 || recorderState === "recording"
? colors.borderSecondary
: colors.surface,
borderRadius: 8,
borderWidth: 1,
borderColor: colors.borderSecondary,
paddingHorizontal: 12,
paddingVertical: 8,
opacity: recordingList.length === 0 || recorderState === "recording" ? 0.6 : 1,
}}
>
<Text style={{ color: colors.textPrimary, fontWeight: "bold", fontSize: 12 }}></Text>
</TouchableOpacity>
</View>
<View
style={{
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 8,
backgroundColor:
recordingFileStatus.type === "success"
? "#43a04722"
: recordingFileStatus.type === "error"
? "#e5393522"
: colors.surface,
borderWidth: 1,
borderColor:
recordingFileStatus.type === "success"
? "#43a04755"
: recordingFileStatus.type === "error"
? "#e5393555"
: colors.borderSecondary,
}}
>
<Text style={{ color: colors.textSecondary, fontSize: 12, lineHeight: 18 }}>{recordingFileStatus.text}</Text>
</View>
</View>
{recordingList.length > 0 && recorderState !== "recording" && (
<View style={styles.recordingList}>
{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",
});
const recordingRow = (
<TouchableOpacity
onPress={() => startPlayback(rec.id)}
onLongPress={() => {
void handleExportRecordingFile(rec.id, dateLabel);
}}
disabled={isPlaying}
activeOpacity={0.72}
style={{
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.backgroundSecondary,
borderRadius: 8,
padding: 12,
gap: 10,
opacity: isPlaying ? 0.6 : 1,
}}
>
<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>
<View style={{ alignItems: "flex-end", gap: 4 }}>
<View style={styles.playbackHint}>
<MaterialCommunityIcons
name={isPlaying ? "pause-circle-outline" : "play-circle-outline"}
size={18}
color={isPlaying ? colors.textTertiary : "#43a047"}
/>
<Text
style={{
color: isPlaying ? colors.textTertiary : colors.textPrimary,
fontSize: 12,
fontWeight: "600",
}}
>
{isPlaying ? "再生中は操作不可" : "タップで再生"}
</Text>
</View>
{!isPlaying && (
<Text style={{ color: colors.textTertiary, fontSize: 10 }}> / </Text>
)}
</View>
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.iconSecondary} />
</TouchableOpacity>
);
if (isPlaying) {
return <View key={rec.id}>{recordingRow}</View>;
}
return (
<Swipeable
key={rec.id}
ref={(instance) => {
recordingSwipeRefs.current[rec.id] = instance;
}}
friction={2}
overshootRight={false}
rightThreshold={48}
renderRightActions={() => (
<View style={styles.deleteAction}>
<MaterialCommunityIcons name="trash-can-outline" size={18} color="#fff" />
<Text style={styles.deleteActionText}></Text>
</View>
)}
onSwipeableOpen={() => confirmDeleteRecording(rec.id, dateLabel)}
>
{recordingRow}
</Swipeable>
);
})}
</View>
)}
<TouchableOpacity
onPress={openRecordingDatabase}
activeOpacity={0.78}
style={[
styles.databaseButton,
{
backgroundColor: fixed.primary,
borderColor: fixed.primary,
},
]}
>
<MaterialCommunityIcons
name="database-search-outline"
size={18}
color={fixed.textOnPrimary}
/>
<Text style={[styles.databaseButtonText, { color: fixed.textOnPrimary }]}>
</Text>
</TouchableOpacity>
</View>
</>
)}
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0099CC",
},
content: {
flex: 1,
backgroundColor: "#f8f8fc",
},
contentInner: {
paddingHorizontal: 14,
paddingBottom: 40,
paddingTop: 20,
gap: 12,
},
debugSection: {
borderRadius: 12,
borderWidth: 1,
padding: 14,
gap: 10,
},
debugTitle: {
fontSize: 15,
fontWeight: "bold",
},
debugDescription: {
fontSize: 12,
lineHeight: 18,
},
debugCurrentText: {
fontSize: 11,
lineHeight: 16,
},
switchRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginTop: 8,
},
statusRow: {
flexDirection: "row",
alignItems: "center",
marginTop: 8,
gap: 8,
},
buttonRow: {
flexDirection: "row",
gap: 8,
marginTop: 10,
flexWrap: "wrap",
},
recordButton: {
backgroundColor: "#e53935",
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 8,
},
neutralButton: {
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 8,
},
primaryButtonText: {
color: "#fff",
fontWeight: "bold",
fontSize: 13,
},
neutralButtonText: {
fontWeight: "bold",
fontSize: 13,
},
fileBox: {
marginTop: 4,
gap: 8,
borderRadius: 8,
padding: 10,
},
recordingList: {
marginTop: 10,
gap: 6,
},
playbackHint: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
deleteAction: {
width: 96,
borderRadius: 8,
backgroundColor: "#e53935",
alignItems: "center",
justifyContent: "center",
marginLeft: 6,
},
deleteActionText: {
color: "#fff",
fontSize: 11,
fontWeight: "bold",
marginTop: 4,
},
databaseButton: {
minHeight: 46,
borderRadius: 10,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 12,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
marginTop: 4,
},
databaseButtonText: {
fontSize: 14,
fontWeight: "bold",
},
});
+12 -1
View File
@@ -16,8 +16,9 @@ import { SwitchArea } from "../atom/SwitchArea";
import { useNotification } from "../../stateBox/useNotifications"; import { useNotification } from "../../stateBox/useNotifications";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem"; import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { useThemeColors, type ColorThemePref } from "@/lib/theme/useThemeColors"; import { useThemeColors, type ColorThemePref } from "@/lib/theme/useThemeColors";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
const versionCode = "7.0.7"; // Update this version code as needed const versionCode = "7.1";
export const SettingTopPage = ({ export const SettingTopPage = ({
testNFC, testNFC,
@@ -29,6 +30,8 @@ export const SettingTopPage = ({
const { expoPushToken } = useNotification(); const { expoPushToken } = useNotification();
const { colors, fixed, colorTheme, setColorTheme } = useThemeColors(); const { colors, fixed, colorTheme, setColorTheme } = useThemeColors();
const navigation = useNavigation<any>(); const navigation = useNavigation<any>();
const { updatePermission } = useTrainMenu();
const showResearchTools = __DEV__ || updatePermission;
return ( return (
<View style={{ height: "100%", backgroundColor: fixed.primary }}> <View style={{ height: "100%", backgroundColor: fixed.primary }}>
@@ -141,6 +144,14 @@ export const SettingTopPage = ({
navigation.navigate("setting", { screen: "DataSourceSettings" }) navigation.navigate("setting", { screen: "DataSourceSettings" })
} }
/> />
{showResearchTools && (
<SettingList
string="調査ツール"
onPress={() =>
navigation.navigate("setting", { screen: "ResearchToolsSettings" })
}
/>
)}
<SectionHeader title="その他" /> <SectionHeader title="その他" />
<SettingList <SettingList
+215 -6
View File
@@ -1,5 +1,11 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { View, Text, ScrollView, Platform } from "react-native"; import {
View,
Text,
ScrollView,
Platform,
TouchableOpacity,
} from "react-native";
import { Switch } from "@rneui/themed"; import { Switch } from "@rneui/themed";
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from "@react-navigation/native";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem"; import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
@@ -9,13 +15,26 @@ import { useThemeColors } from "@/lib/theme";
import { Asset } from "expo-asset"; import { Asset } from "expo-asset";
import { useAudioPlayer, setAudioModeAsync } from "expo-audio"; import { useAudioPlayer, setAudioModeAsync } from "expo-audio";
import type { AudioSource } from "expo-audio"; import type { AudioSource } from "expo-audio";
import {
DEFAULT_BACKGROUND_RIKKA_TRIGGER_SOURCE,
type BackgroundRikkaTriggerSource,
} from "@/lib/backgroundRikkaAnnouncements";
import { VoicepeakDebugLogSection } from "@/components/Settings/VoicepeakDebugLogSection";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
const previewSound = require("../../assets/sound/rikka-test.mp3"); const previewSound = require("../../assets/sound/rikka-test.mp3");
export const SoundSettings = () => { export const SoundSettings = () => {
const { goBack } = useNavigation(); const { goBack } = useNavigation();
const { colors, fixed } = useThemeColors(); const { colors, fixed } = useThemeColors();
const { restrictedSoundPermission } = useTrainMenu();
const [delayAnnouncement, setDelayAnnouncement] = useState(false); const [delayAnnouncement, setDelayAnnouncement] = useState(false);
const [voicepeakEnabled, setVoicepeakEnabled] = useState(false);
const [backgroundRikkaEnabled, setBackgroundRikkaEnabled] = useState(false);
const [backgroundRikkaSource, setBackgroundRikkaSource] =
useState<BackgroundRikkaTriggerSource>(
DEFAULT_BACKGROUND_RIKKA_TRIGGER_SOURCE
);
// expo-asset でローカルパスを取得し、expo-audio に渡す // expo-asset でローカルパスを取得し、expo-audio に渡す
const [resolvedSource, setResolvedSource] = useState<AudioSource>(null); const [resolvedSource, setResolvedSource] = useState<AudioSource>(null);
@@ -54,11 +73,34 @@ export const SoundSettings = () => {
const previewPlayer = useAudioPlayer(resolvedSource); const previewPlayer = useAudioPlayer(resolvedSource);
useEffect(() => { useEffect(() => {
AS.getItem(STORAGE_KEYS.SOUND_DELAY_ANNOUNCEMENT) Promise.all([
.then((v) => setDelayAnnouncement(v === true || v === "true")) AS.getItem(STORAGE_KEYS.SOUND_DELAY_ANNOUNCEMENT).catch(() => "false"),
.catch(() => { AS.getItem(STORAGE_KEYS.VOICEPEAK_ENABLED).catch(() => "false"),
// 未設定時はデフォルト値 false のまま AS.getItem(STORAGE_KEYS.BACKGROUND_RIKKA_ANNOUNCEMENT).catch(
}); () => "false"
),
AS.getItem(STORAGE_KEYS.BACKGROUND_RIKKA_TRIGGER_SOURCE).catch(
() => DEFAULT_BACKGROUND_RIKKA_TRIGGER_SOURCE
),
]).then(
([
delayValue,
enabledValue,
backgroundRikkaValue,
backgroundRikkaSourceValue,
]) => {
setDelayAnnouncement(delayValue === true || delayValue === "true");
setVoicepeakEnabled(enabledValue === true || enabledValue === "true");
setBackgroundRikkaEnabled(
backgroundRikkaValue === true || backgroundRikkaValue === "true"
);
setBackgroundRikkaSource(
backgroundRikkaSourceValue === "trainPosition"
? "trainPosition"
: DEFAULT_BACKGROUND_RIKKA_TRIGGER_SOURCE
);
}
);
}, []); }, []);
const playPreview = useCallback(async () => { const playPreview = useCallback(async () => {
@@ -85,6 +127,26 @@ export const SoundSettings = () => {
} }
}; };
const handleVoicepeakToggle = (value: boolean) => {
setVoicepeakEnabled(value);
AS.setItem(STORAGE_KEYS.VOICEPEAK_ENABLED, value.toString());
};
const handleBackgroundRikkaToggle = (value: boolean) => {
setBackgroundRikkaEnabled(value);
AS.setItem(
STORAGE_KEYS.BACKGROUND_RIKKA_ANNOUNCEMENT,
value.toString()
);
};
const handleBackgroundRikkaSource = (
value: BackgroundRikkaTriggerSource
) => {
setBackgroundRikkaSource(value);
AS.setItem(STORAGE_KEYS.BACKGROUND_RIKKA_TRIGGER_SOURCE, value);
};
return ( return (
<View style={{ height: "100%", backgroundColor: fixed.primary }}> <View style={{ height: "100%", backgroundColor: fixed.primary }}>
<SheetHeaderItem <SheetHeaderItem
@@ -112,6 +174,153 @@ export const SoundSettings = () => {
color={fixed.primary} color={fixed.primary}
/> />
</View> </View>
{restrictedSoundPermission && (
<View
style={{
paddingHorizontal: 15,
paddingTop: 18,
paddingBottom: 10,
borderBottomWidth: 1,
borderBottomColor: colors.borderSecondary ?? "#ccc",
backgroundColor: colors.surface,
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
marginBottom: 12,
}}
>
<Text style={{ flex: 1, fontSize: 16, color: colors.text }}>
Voicepeak
</Text>
<Switch
value={voicepeakEnabled}
onValueChange={handleVoicepeakToggle}
color={fixed.primary}
/>
</View>
<Text
style={{
fontSize: 12,
lineHeight: 18,
color: colors.textSecondary ?? colors.text,
marginBottom: 8,
}}
>
LED
2 30 30 Voicepeak API
</Text>
<View
style={{
flexDirection: "row",
alignItems: "center",
marginBottom: 8,
}}
>
<View style={{ flex: 1, paddingRight: 12 }}>
<Text style={{ fontSize: 15, color: colors.text }}>
</Text>
<Text
style={{
marginTop: 4,
fontSize: 12,
lineHeight: 18,
color: colors.textSecondary ?? colors.text,
}}
>
使
</Text>
</View>
<Switch
value={backgroundRikkaEnabled}
onValueChange={handleBackgroundRikkaToggle}
disabled={!voicepeakEnabled}
color={fixed.primary}
/>
</View>
{backgroundRikkaEnabled && (
<View style={{ gap: 8, paddingBottom: 14 }}>
<Text style={{ fontSize: 13, color: colors.text }}>
</Text>
<View style={{ flexDirection: "row", gap: 8 }}>
{(
[
["deviceLocation", "端末の駅接近"],
["trainPosition", "列車走行位置"],
] as const
).map(([value, label]) => {
const selected = backgroundRikkaSource === value;
return (
<TouchableOpacity
key={value}
accessibilityRole="button"
accessibilityState={{ selected }}
onPress={() => handleBackgroundRikkaSource(value)}
style={{
flex: 1,
paddingHorizontal: 10,
paddingVertical: 10,
borderWidth: 1,
borderColor: selected
? fixed.primary
: colors.borderSecondary ?? "#aaa",
borderRadius: 10,
backgroundColor: selected
? fixed.primary
: colors.background,
}}
>
<Text
style={{
textAlign: "center",
fontSize: 13,
fontWeight: selected ? "700" : "400",
color: selected ? "#fff" : colors.text,
}}
>
{label}
</Text>
</TouchableOpacity>
);
})}
</View>
<Text
style={{
fontSize: 12,
lineHeight: 18,
color: colors.textSecondary ?? colors.text,
}}
>
{backgroundRikkaSource === "deviceLocation"
? "実利用向け。iOSが端末の駅接近を検知するため、アプリ停止中も通知できます。"
: "検証向け。列車走行位置から次駅が変わった時に通知します。モック走行にも対応しますが、アプリ停止後の監視にはサーバープッシュが必要です。"}
</Text>
</View>
)}
<Text
style={{
fontSize: 12,
lineHeight: 18,
color: colors.textSecondary ?? colors.text,
paddingBottom: 8,
}}
>
APIから取得しますAPI設定やトークンの入力は不要です
</Text>
</View>
)}
{restrictedSoundPermission && <VoicepeakDebugLogSection />}
</ScrollView> </ScrollView>
</View> </View>
); );
@@ -0,0 +1,214 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Text, TouchableOpacity, View } from "react-native";
import { setAudioModeAsync, useAudioPlayer } from "expo-audio";
import { WebView } from "react-native-webview";
import { useThemeColors } from "@/lib/theme";
import {
requestVoicepeakSpeechBytes,
VoicepeakRequestError,
} from "@/lib/voicepeak";
import {
createVoicepeakAudioSource,
type PreparedVoicepeakAudio,
} from "@/lib/voicepeakAudioSource";
import type { VoicepeakDebugLogEntry } from "@/lib/voicepeakDebugLog";
type DebugAudioAction = "fetch" | "force";
export const VoicepeakDebugAudioActions = ({
log,
onComplete,
}: {
log: VoicepeakDebugLogEntry;
onComplete?: () => void | Promise<void>;
}) => {
const { colors, fixed } = useThemeColors();
const player = useAudioPlayer(null);
const [activeAction, setActiveAction] = useState<DebugAudioAction | null>(
null,
);
const [nativeAudio, setNativeAudio] = useState<{
html: string;
key: number;
} | null>(null);
const requestControllerRef = useRef<AbortController | null>(null);
const cleanupAudioRef = useRef<(() => void) | undefined>(undefined);
const stopCurrentAudio = useCallback(() => {
try {
player.pause();
} catch {
// Player may not have a source yet.
}
setNativeAudio(null);
cleanupAudioRef.current?.();
cleanupAudioRef.current = undefined;
}, [player]);
useEffect(
() => () => {
requestControllerRef.current?.abort();
stopCurrentAudio();
},
[log.id, stopCurrentAudio],
);
const playAudio = useCallback(
async (audio: PreparedVoicepeakAudio) => {
stopCurrentAudio();
cleanupAudioRef.current =
audio.kind === "expo-audio" ? audio.cleanup : undefined;
await setAudioModeAsync({
playsInSilentMode: true,
shouldPlayInBackground: false,
interruptionMode: "duckOthers",
});
if (audio.kind === "native-webview") {
setNativeAudio({ html: audio.html, key: Date.now() });
return;
}
player.replace(audio.source);
player.volume = 1;
await player.seekTo(0);
player.play();
},
[player, stopCurrentAudio],
);
const runAction = useCallback(
async (force: boolean) => {
if (activeAction) return;
const action: DebugAudioAction = force ? "force" : "fetch";
const controller = new AbortController();
requestControllerRef.current = controller;
setActiveAction(action);
try {
const bytes = await requestVoicepeakSpeechBytes({
text: log.text,
settings: { enabled: true },
signal: controller.signal,
format: log.format,
force,
});
if (controller.signal.aborted) return;
const audio = await createVoicepeakAudioSource(bytes, log.format);
if (controller.signal.aborted) {
if (audio.kind === "expo-audio") audio.cleanup?.();
return;
}
await playAudio(audio);
await onComplete?.();
} catch (error) {
const aborted =
controller.signal.aborted ||
(error instanceof VoicepeakRequestError && error.code === "ABORTED");
if (!aborted) {
const message =
error instanceof VoicepeakRequestError
? `${error.message}\n\nHTTP: ${error.status || "-"}\nCode: ${
error.code
}\nRequest ID: ${error.requestId || "-"}`
: error instanceof Error
? error.message
: String(error);
Alert.alert(
force ? "音声の再作成に失敗しました" : "音声の取得に失敗しました",
message,
);
}
} finally {
if (requestControllerRef.current === controller) {
requestControllerRef.current = null;
}
setActiveAction(null);
}
},
[activeAction, log.format, log.text, onComplete, playAudio],
);
const disabled = activeAction !== null;
const borderColor = colors.borderSecondary ?? "#ccc";
return (
<View style={{ marginTop: 14 }}>
<Text
style={{
marginBottom: 8,
color: colors.textSecondary ?? colors.text,
fontSize: 12,
lineHeight: 17,
}}
>
使
</Text>
<View style={{ flexDirection: "row", gap: 8 }}>
<TouchableOpacity
accessibilityRole="button"
disabled={disabled}
onPress={() => void runAction(false)}
style={{
flex: 1,
paddingVertical: 11,
borderRadius: 8,
borderWidth: 1,
borderColor: fixed.primary,
opacity: disabled ? 0.55 : 1,
}}
>
<Text
style={{
color: fixed.primary,
textAlign: "center",
fontWeight: "600",
}}
>
{activeAction === "fetch" ? "取得中…" : "取得・再生"}
</Text>
</TouchableOpacity>
<TouchableOpacity
accessibilityRole="button"
disabled={disabled}
onPress={() => void runAction(true)}
style={{
flex: 1,
paddingVertical: 11,
borderRadius: 8,
backgroundColor: disabled ? borderColor : fixed.primary,
opacity: disabled ? 0.55 : 1,
}}
>
<Text
style={{ color: "#fff", textAlign: "center", fontWeight: "600" }}
>
{activeAction === "force" ? "再作成中…" : "再作成して再生"}
</Text>
</TouchableOpacity>
</View>
{nativeAudio && (
<WebView
key={nativeAudio.key}
source={{ html: nativeAudio.html }}
originWhitelist={["*"]}
javaScriptEnabled
scrollEnabled={false}
mediaPlaybackRequiresUserAction={false}
allowsInlineMediaPlayback
onMessage={() => setNativeAudio(null)}
style={{
position: "absolute",
width: 1,
height: 1,
opacity: 0,
}}
/>
)}
</View>
);
};
@@ -0,0 +1,373 @@
import React, { useCallback, useState } from "react";
import {
Alert,
Modal,
Pressable,
ScrollView,
Text,
TouchableOpacity,
View,
} from "react-native";
import * as Clipboard from "expo-clipboard";
import { useThemeColors } from "@/lib/theme";
import {
clearVoicepeakDebugLogs,
getVoicepeakDebugLogs,
type VoicepeakDebugLogEntry,
} from "@/lib/voicepeakDebugLog";
import { VoicepeakDebugAudioActions } from "@/components/Settings/VoicepeakDebugAudioActions";
const formatDebugLog = (log: VoicepeakDebugLogEntry) =>
JSON.stringify(log, null, 2);
const formatAllDebugLogs = (logs: VoicepeakDebugLogEntry[]) =>
JSON.stringify(
{
exportedAt: new Date().toISOString(),
retentionDays: 7,
count: logs.length,
logs,
},
null,
2,
);
export const VoicepeakDebugLogSection = () => {
const { colors, fixed } = useThemeColors();
const [logs, setLogs] = useState<VoicepeakDebugLogEntry[]>([]);
const [selectedLog, setSelectedLog] = useState<VoicepeakDebugLogEntry | null>(
null,
);
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [expanded, setExpanded] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
setLogs(await getVoicepeakDebugLogs());
} catch (error) {
console.warn("Failed to load Voicepeak debug logs", error);
} finally {
setLoading(false);
}
}, []);
const toggleExpanded = useCallback(() => {
setExpanded((current) => {
const next = !current;
if (next) void refresh();
return next;
});
}, [refresh]);
const copyText = useCallback(async (text: string) => {
await Clipboard.setStringAsync(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, []);
const clearLogs = useCallback(() => {
Alert.alert(
"音声作成ログを削除",
"端末に保存されたVoicepeakデバッグ履歴をすべて削除します。",
[
{ text: "キャンセル", style: "cancel" },
{
text: "削除",
style: "destructive",
onPress: () => {
void clearVoicepeakDebugLogs().then(() => {
setLogs([]);
setSelectedLog(null);
});
},
},
],
);
}, []);
const borderColor = colors.borderSecondary ?? "#ccc";
const secondaryText = colors.textSecondary ?? colors.text;
return (
<View
style={{
paddingHorizontal: 15,
paddingVertical: 18,
borderBottomWidth: 1,
borderBottomColor: borderColor,
backgroundColor: colors.surface,
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
marginBottom: expanded ? 8 : 0,
}}
>
<TouchableOpacity
accessibilityRole="button"
accessibilityState={{ expanded }}
onPress={toggleExpanded}
style={{
flex: 1,
flexDirection: "row",
alignItems: "center",
paddingVertical: 2,
}}
>
<Text
style={{
flex: 1,
fontSize: 16,
fontWeight: "600",
color: colors.text,
}}
>
Voicepeak
{expanded ? `${logs.length}件)` : ""}
</Text>
<Text style={{ color: fixed.primary, padding: 8 }}>
{expanded ? "閉じる ▲" : "表示する ▼"}
</Text>
</TouchableOpacity>
{expanded && (
<TouchableOpacity onPress={() => void refresh()}>
<Text style={{ color: fixed.primary, padding: 8 }}>
{loading ? "読込中" : "更新"}
</Text>
</TouchableOpacity>
)}
</View>
{expanded && (
<>
<Text
style={{
fontSize: 12,
lineHeight: 18,
color: secondaryText,
marginBottom: 12,
}}
>
APIへ送信したテキストHTTP結果
APIトークンは保存しません7
</Text>
<View style={{ flexDirection: "row", gap: 8, marginBottom: 12 }}>
<TouchableOpacity
disabled={logs.length === 0}
onPress={() => void copyText(formatAllDebugLogs(logs))}
style={{
flex: 1,
paddingVertical: 10,
borderRadius: 8,
backgroundColor: logs.length > 0 ? fixed.primary : borderColor,
}}
>
<Text
style={{
color: "#fff",
textAlign: "center",
fontWeight: "600",
}}
>
{copied ? "コピーしました" : "全履歴をコピー"}
</Text>
</TouchableOpacity>
<TouchableOpacity
disabled={logs.length === 0}
onPress={clearLogs}
style={{
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 8,
borderWidth: 1,
borderColor,
}}
>
<Text
style={{ color: logs.length > 0 ? "#d32f2f" : secondaryText }}
>
</Text>
</TouchableOpacity>
</View>
{logs.length === 0 ? (
<Text style={{ color: secondaryText, paddingVertical: 10 }}>
</Text>
) : (
logs.slice(0, 200).map((log) => {
const statusColor =
log.status === "success"
? "#2e7d32"
: log.status === "error"
? "#d32f2f"
: "#ed6c02";
return (
<TouchableOpacity
key={log.id}
onPress={() => setSelectedLog(log)}
style={{
paddingVertical: 11,
borderTopWidth: 1,
borderTopColor: borderColor,
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
marginBottom: 5,
}}
>
<Text style={{ color: statusColor, fontWeight: "700" }}>
{log.status === "success"
? "成功"
: log.status === "error"
? "失敗"
: "処理中"}
</Text>
<Text
style={{
flex: 1,
textAlign: "right",
color: secondaryText,
fontSize: 12,
}}
>
{new Date(log.createdAt).toLocaleString("ja-JP")}
</Text>
</View>
<Text
numberOfLines={2}
style={{ color: colors.text, fontSize: 13, lineHeight: 18 }}
>
{log.text}
</Text>
<Text
style={{
color: secondaryText,
fontSize: 11,
marginTop: 5,
}}
>
{log.codePointCount ?? Array.from(log.text).length}
{log.chunkIndex && log.totalChunks
? ` ・ 分割 ${log.chunkIndex}/${log.totalChunks}`
: ""}
{log.attemptNumber ? ` ・ 試行 ${log.attemptNumber}` : ""}
{typeof log.httpStatus === "number"
? ` ・ HTTP ${log.httpStatus}`
: ""}
{log.errorCode ? `${log.errorCode}` : ""}
{log.cacheStatus ? `${log.cacheStatus}` : ""}
{typeof log.durationMilliseconds === "number"
? `${log.durationMilliseconds}ms`
: ""}
</Text>
</TouchableOpacity>
);
})
)}
</>
)}
<Modal
visible={selectedLog !== null}
transparent
animationType="fade"
onRequestClose={() => setSelectedLog(null)}
>
<Pressable
onPress={() => setSelectedLog(null)}
style={{
flex: 1,
justifyContent: "center",
padding: 20,
backgroundColor: "rgba(0,0,0,0.55)",
}}
>
<Pressable
onPress={(event) => event.stopPropagation()}
style={{
maxHeight: "85%",
padding: 16,
borderRadius: 12,
backgroundColor: colors.surface,
}}
>
<Text
style={{
fontSize: 17,
fontWeight: "700",
color: colors.text,
marginBottom: 12,
}}
>
Voicepeakログ詳細
</Text>
{selectedLog && (
<VoicepeakDebugAudioActions
log={selectedLog}
onComplete={refresh}
/>
)}
<ScrollView>
<Text
selectable
style={{
color: colors.text,
fontSize: 12,
lineHeight: 18,
fontFamily: "monospace",
}}
>
{selectedLog ? formatDebugLog(selectedLog) : ""}
</Text>
</ScrollView>
<View style={{ flexDirection: "row", gap: 8, marginTop: 14 }}>
<TouchableOpacity
onPress={() =>
selectedLog && void copyText(formatDebugLog(selectedLog))
}
style={{
flex: 1,
paddingVertical: 11,
borderRadius: 8,
backgroundColor: fixed.primary,
}}
>
<Text
style={{
color: "#fff",
textAlign: "center",
fontWeight: "600",
}}
>
{copied ? "コピーしました" : "詳細をコピー"}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setSelectedLog(null)}
style={{
paddingHorizontal: 18,
paddingVertical: 11,
borderRadius: 8,
borderWidth: 1,
borderColor,
}}
>
<Text style={{ color: colors.text }}></Text>
</TouchableOpacity>
</View>
</Pressable>
</Pressable>
</Modal>
</View>
);
};
+80 -27
View File
@@ -10,7 +10,7 @@ import {
ToastAndroid, ToastAndroid,
} from "react-native"; } from "react-native";
import { createStackNavigator } from "@react-navigation/stack"; import { createStackNavigator } from "@react-navigation/stack";
import { TransitionPresets } from "@react-navigation/stack"; import { pushTransitionOptions } from "@/lib/stackOption";
import * as ExpoFelicaReader from "../../modules/expo-felica-reader/src"; import * as ExpoFelicaReader from "../../modules/expo-felica-reader/src";
import * as Updates from "expo-updates"; import * as Updates from "expo-updates";
import { AS } from "../../storageControl"; import { AS } from "../../storageControl";
@@ -22,6 +22,7 @@ import { FavoriteSettings } from "./FavoriteSettings";
import { NotificationSettings } from "./NotificationSettings"; import { NotificationSettings } from "./NotificationSettings";
import { LauncherIconSettings } from "./LauncherIconSettings"; import { LauncherIconSettings } from "./LauncherIconSettings";
import { DataSourceSettings } from "./DataSourceSettings"; import { DataSourceSettings } from "./DataSourceSettings";
import { ResearchToolsSettings } from "./ResearchToolsSettings";
import { FelicaHistoryPage } from "./FelicaHistoryPage"; import { FelicaHistoryPage } from "./FelicaHistoryPage";
import { SoundSettings } from "./SoundSettings"; import { SoundSettings } from "./SoundSettings";
import { OperationInfoSettings } from "./OperationInfoSettings"; import { OperationInfoSettings } from "./OperationInfoSettings";
@@ -31,6 +32,19 @@ import {
type IconDisplayMode, type IconDisplayMode,
} from "@/lib/iconDisplayMode"; } from "@/lib/iconDisplayMode";
const readStoredValue = async <T,>(
key: string,
fallback: T,
mapValue?: (value: any) => T,
) => {
try {
const value = await AS.getItem(key);
return mapValue ? mapValue(value) : (value as T);
} catch {
return fallback;
}
};
const Stack = createStackNavigator(); const Stack = createStackNavigator();
export default function Setting(props) { export default function Setting(props) {
const { const {
@@ -49,23 +63,51 @@ export default function Setting(props) {
const [operationLandscapeEnabled, setOperationLandscapeEnabled] = useState(false); const [operationLandscapeEnabled, setOperationLandscapeEnabled] = useState(false);
const [operationCaptureEnabled, setOperationCaptureEnabled] = useState(false); const [operationCaptureEnabled, setOperationCaptureEnabled] = useState(false);
useLayoutEffect(() => { useLayoutEffect(() => {
AS.getItem(STORAGE_KEYS.ICON_SWITCH).then((value) => void Promise.all([
setIconSetting(normalizeIconDisplayMode(value)), readStoredValue(STORAGE_KEYS.ICON_SWITCH, "original", normalizeIconDisplayMode),
); readStoredValue(STORAGE_KEYS.MAP_SWITCH, false, (value) => value === true || value === "true"),
AS.getItem(STORAGE_KEYS.MAP_SWITCH).then(setMapSwitch); readStoredValue(STORAGE_KEYS.STATION_SWITCH, false, (value) => value === true || value === "true"),
AS.getItem(STORAGE_KEYS.STATION_SWITCH).then(setStationMenu); readStoredValue(STORAGE_KEYS.USE_PDF_VIEW, false, (value) => value === true || value === "true"),
AS.getItem(STORAGE_KEYS.USE_PDF_VIEW).then(setUsePDFView); readStoredValue(STORAGE_KEYS.TRAIN_SWITCH, false, (value) => value === true || value === "true"),
AS.getItem(STORAGE_KEYS.TRAIN_SWITCH).then(setTrainMenu); readStoredValue(STORAGE_KEYS.TRAIN_POSITION_SWITCH, false, (value) => value === true || value === "true"),
AS.getItem(STORAGE_KEYS.TRAIN_POSITION_SWITCH).then(setTrainPosition); readStoredValue(STORAGE_KEYS.HEADER_SIZE, "default"),
AS.getItem(STORAGE_KEYS.HEADER_SIZE).then(setHeaderSize); readStoredValue(STORAGE_KEYS.START_PAGE, false, (value) => value === true || value === "true"),
AS.getItem(STORAGE_KEYS.START_PAGE).then(setStartPage); readStoredValue(STORAGE_KEYS.UI_SETTING, "tokyo"),
AS.getItem(STORAGE_KEYS.UI_SETTING).then(setUiSetting); readStoredValue(
AS.getItem(STORAGE_KEYS.OPERATION_INFO_LANDSCAPE_ENABLED).then((value) => STORAGE_KEYS.OPERATION_INFO_LANDSCAPE_ENABLED,
setOperationLandscapeEnabled(value === true || value === "true"), false,
); (value) => value === true || value === "true",
AS.getItem(STORAGE_KEYS.OPERATION_INFO_CAPTURE_ENABLED).then((value) => ),
setOperationCaptureEnabled(value === true || value === "true"), readStoredValue(
); STORAGE_KEYS.OPERATION_INFO_CAPTURE_ENABLED,
false,
(value) => value === true || value === "true",
),
]).then(([
nextIconSetting,
nextMapSwitch,
nextStationMenu,
nextUsePdfView,
nextTrainMenu,
nextTrainPosition,
nextHeaderSize,
nextStartPage,
nextUiSetting,
nextOperationLandscapeEnabled,
nextOperationCaptureEnabled,
]) => {
setIconSetting(nextIconSetting);
setMapSwitch(nextMapSwitch);
setStationMenu(nextStationMenu);
setUsePDFView(nextUsePdfView);
setTrainMenu(nextTrainMenu);
setTrainPosition(nextTrainPosition);
setHeaderSize(nextHeaderSize);
setStartPage(nextStartPage);
setUiSetting(nextUiSetting);
setOperationLandscapeEnabled(nextOperationLandscapeEnabled);
setOperationCaptureEnabled(nextOperationCaptureEnabled);
});
}, []); }, []);
const testNFC = async () => { const testNFC = async () => {
console.log("Testing NFC..."); console.log("Testing NFC...");
@@ -112,7 +154,7 @@ export default function Setting(props) {
name="settingTopPage" name="settingTopPage"
options={{ options={{
gestureEnabled: false, gestureEnabled: false,
...TransitionPresets.SlideFromRightIOS, ...pushTransitionOptions,
cardOverlayEnabled: true, cardOverlayEnabled: true,
headerTransparent: true, headerTransparent: true,
headerShown: false, headerShown: false,
@@ -132,7 +174,7 @@ export default function Setting(props) {
name="LayoutSettings" name="LayoutSettings"
options={{ options={{
gestureEnabled: true, gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS, ...pushTransitionOptions,
cardOverlayEnabled: true, cardOverlayEnabled: true,
headerTransparent: true, headerTransparent: true,
headerShown: false, headerShown: false,
@@ -165,7 +207,7 @@ export default function Setting(props) {
name="NotificationSettings" name="NotificationSettings"
options={{ options={{
//gestureEnabled: true, //gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS, ...pushTransitionOptions,
cardOverlayEnabled: true, cardOverlayEnabled: true,
headerTransparent: true, headerTransparent: true,
headerShown: false, headerShown: false,
@@ -176,7 +218,7 @@ export default function Setting(props) {
name="LauncherIconSettings" name="LauncherIconSettings"
options={{ options={{
gestureEnabled: true, gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS, ...pushTransitionOptions,
cardOverlayEnabled: true, cardOverlayEnabled: true,
headerTransparent: true, headerTransparent: true,
headerShown: false, headerShown: false,
@@ -187,7 +229,7 @@ export default function Setting(props) {
name="FavoriteSettings" name="FavoriteSettings"
options={{ options={{
gestureEnabled: true, gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS, ...pushTransitionOptions,
cardOverlayEnabled: true, cardOverlayEnabled: true,
headerTransparent: true, headerTransparent: true,
headerShown: false, headerShown: false,
@@ -198,18 +240,29 @@ export default function Setting(props) {
name="DataSourceSettings" name="DataSourceSettings"
options={{ options={{
gestureEnabled: true, gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS, ...pushTransitionOptions,
cardOverlayEnabled: true, cardOverlayEnabled: true,
headerTransparent: true, headerTransparent: true,
headerShown: false, headerShown: false,
}} }}
component={DataSourceSettings} component={DataSourceSettings}
/> />
<Stack.Screen
name="ResearchToolsSettings"
options={{
gestureEnabled: true,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
}}
component={ResearchToolsSettings}
/>
<Stack.Screen <Stack.Screen
name="FelicaHistoryPage" name="FelicaHistoryPage"
options={{ options={{
gestureEnabled: true, gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS, ...pushTransitionOptions,
cardOverlayEnabled: true, cardOverlayEnabled: true,
headerTransparent: true, headerTransparent: true,
headerShown: false, headerShown: false,
@@ -220,7 +273,7 @@ export default function Setting(props) {
name="SoundSettings" name="SoundSettings"
options={{ options={{
gestureEnabled: true, gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS, ...pushTransitionOptions,
cardOverlayEnabled: true, cardOverlayEnabled: true,
headerTransparent: true, headerTransparent: true,
headerShown: false, headerShown: false,
@@ -231,7 +284,7 @@ export default function Setting(props) {
name="OperationInfoSettings" name="OperationInfoSettings"
options={{ options={{
gestureEnabled: true, gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS, ...pushTransitionOptions,
cardOverlayEnabled: true, cardOverlayEnabled: true,
headerTransparent: true, headerTransparent: true,
headerShown: false, headerShown: false,
+11 -8
View File
@@ -59,7 +59,7 @@ export const ListViewItem: FC<{
const [ const [
typeString, typeString,
trainName, trainName,
fontAvailable, fontFamily,
isOneMan, isOneMan,
infogram, infogram,
priority, priority,
@@ -79,7 +79,7 @@ export const ListViewItem: FC<{
to_data_color, to_data_color,
train_number_override, train_number_override,
} = customTrainDataDetector(d.trainNumber, allCustomTrainData); } = customTrainDataDetector(d.trainNumber, allCustomTrainData);
const [typeString, fontAvailable, isOneMan] = getStringConfig( const [typeString, fontFamily, isOneMan] = getStringConfig(
type, type,
d.trainNumber d.trainNumber
); );
@@ -111,7 +111,7 @@ export const ListViewItem: FC<{
return [ return [
typeString, typeString,
displayName, displayName,
fontAvailable, fontFamily,
isOneMan, isOneMan,
infogram, infogram,
priority, priority,
@@ -128,7 +128,7 @@ export const ListViewItem: FC<{
return [ return [
typeString, typeString,
"", "",
fontAvailable, fontFamily,
isOneMan, isOneMan,
infogram, infogram,
priority, priority,
@@ -145,7 +145,7 @@ export const ListViewItem: FC<{
migrateTrainName( migrateTrainName(
trainDataArray[trainDataArray.length - 1].split(",")[0] + "行き" trainDataArray[trainDataArray.length - 1].split(",")[0] + "行き"
), ),
fontAvailable, fontFamily,
isOneMan, isOneMan,
infogram, infogram,
priority, priority,
@@ -314,15 +314,18 @@ export const ListViewItem: FC<{
<Text <Text
style={{ style={{
fontSize: 15, fontSize: 15,
fontFamily: fontAvailable ? "JR-Nishi" : undefined, fontFamily,
fontWeight: !fontAvailable ? "bold" : undefined, fontWeight: fontFamily ? undefined : "bold",
paddingTop: fontAvailable ? 2 : 0, paddingTop: fontFamily ? 2 : 0,
paddingLeft: 10, paddingLeft: 10,
color: isCancelled ? "gray" : color, color: isCancelled ? "gray" : color,
textDecorationLine: isCancelled ? "line-through" : "none", textDecorationLine: isCancelled ? "line-through" : "none",
}} }}
> >
{typeString} {typeString}
{fontFamily === "JR-WEST-PLUS" ? (
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
) : null}
</Text> </Text>
{showVehicle {showVehicle
? (() => { ? (() => {
+1 -1
View File
@@ -1,5 +1,5 @@
import React, { FC } from "react"; import React, { FC } from "react";
import { Image, StyleSheet, View } from "react-native"; import { Image, StyleSheet, View, Platform } from "react-native";
import { Marker } from "react-native-maps"; import { Marker } from "react-native-maps";
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from "@react-navigation/native";
import { useStationList } from "@/stateBox/useStationList"; import { useStationList } from "@/stateBox/useStationList";
+3 -2
View File
@@ -21,7 +21,8 @@ export const SimpleSwitch = ({
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<TouchableOpacity <TouchableOpacity
style={{ style={{
backgroundColor: bool == value.toString() ? color : null, backgroundColor:
bool === value || String(bool) === String(value) ? color : null,
padding: 5, padding: 5,
borderRadius: 5, borderRadius: 5,
margin: 10, margin: 10,
@@ -32,7 +33,7 @@ export const SimpleSwitch = ({
}} }}
onPress={() => { onPress={() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setBool(value.toString()); setBool(value);
}} }}
> >
<Image <Image
+1 -1
View File
@@ -66,7 +66,7 @@ export const SwitchArea = ({
subText={trueText} subText={trueText}
/> />
</View> </View>
{bool == "true" && children} {(bool === true || String(bool) === "true") && children}
</View> </View>
); );
}; };
+44 -3
View File
@@ -1,6 +1,6 @@
import React, { useRef, useState, useEffect, useLayoutEffect } from "react"; import React, { useRef, useState, useEffect, useLayoutEffect } from "react";
import { View, Text, TouchableOpacity, Linking } from "react-native"; import { View, Text, TouchableOpacity, Linking, Platform } from "react-native";
import MapView from "react-native-maps"; import MapView, { Marker } from "react-native-maps";
import { useCurrentTrain } from "../stateBox/useCurrentTrain"; import { useCurrentTrain } from "../stateBox/useCurrentTrain";
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from "@react-navigation/native";
import lineColorList from "../assets/originData/lineColorList"; import lineColorList from "../assets/originData/lineColorList";
@@ -12,6 +12,10 @@ import { UsefulBox } from "./TrainMenu/UsefulBox";
import { MapsButton } from "./TrainMenu/MapsButton"; import { MapsButton } from "./TrainMenu/MapsButton";
import { useStationList } from "@/stateBox/useStationList"; import { useStationList } from "@/stateBox/useStationList";
import { useThemeColors } from "@/lib/theme"; import { useThemeColors } from "@/lib/theme";
import { useUserPosition } from "@/stateBox/useUserPosition";
const ANDROID_USER_LOCATION_MARKER_SIZE = 18;
const ANDROID_USER_LOCATION_INNER_SIZE = 8;
export default function TrainMenu({ style }) { export default function TrainMenu({ style }) {
const { fixed, isDark } = useThemeColors(); const { fixed, isDark } = useThemeColors();
const { webview } = useCurrentTrain(); const { webview } = useCurrentTrain();
@@ -24,6 +28,7 @@ export default function TrainMenu({ style }) {
mapsStationData: stationData, mapsStationData: stationData,
} = useTrainMenu(); } = useTrainMenu();
const { originalStationList } = useStationList(); const { originalStationList } = useStationList();
const { position } = useUserPosition();
useEffect(() => { useEffect(() => {
const stationPinData = []; const stationPinData = [];
Object.keys(lineList_LineWebID).forEach((d, indexBase) => { Object.keys(lineList_LineWebID).forEach((d, indexBase) => {
@@ -49,7 +54,7 @@ export default function TrainMenu({ style }) {
<View style={{ height: "100%", backgroundColor: fixed.primary, ...style }}> <View style={{ height: "100%", backgroundColor: fixed.primary, ...style }}>
<MapView <MapView
style={{ flex: 1, width: "100%", height: "100%" }} style={{ flex: 1, width: "100%", height: "100%" }}
showsUserLocation={true} showsUserLocation={Platform.OS !== "android"}
loadingEnabled={true} loadingEnabled={true}
showsMyLocationButton={false} showsMyLocationButton={false}
moveOnMarkerPress={false} moveOnMarkerPress={false}
@@ -64,6 +69,42 @@ export default function TrainMenu({ style }) {
longitudeDelta: 1.8, longitudeDelta: 1.8,
}} }}
> >
{Platform.OS === "android" && position ? (
<Marker
key="android-user-location"
coordinate={{
latitude: position.coords.latitude,
longitude: position.coords.longitude,
}}
anchor={{ x: 0.5, y: 0.5 }}
tracksViewChanges={false}
zIndex={999}
>
<View
style={{
width: ANDROID_USER_LOCATION_MARKER_SIZE,
height: ANDROID_USER_LOCATION_MARKER_SIZE,
borderRadius: ANDROID_USER_LOCATION_MARKER_SIZE / 2,
backgroundColor: "rgba(10, 132, 255, 0.24)",
borderWidth: 1,
borderColor: "rgba(10, 132, 255, 0.42)",
alignItems: "center",
justifyContent: "center",
}}
>
<View
style={{
width: ANDROID_USER_LOCATION_INNER_SIZE,
height: ANDROID_USER_LOCATION_INNER_SIZE,
borderRadius: ANDROID_USER_LOCATION_INNER_SIZE / 2,
backgroundColor: "#0A84FF",
borderWidth: 1,
borderColor: "#ffffff",
}}
/>
</View>
</Marker>
) : null}
{stationPin.map(({ D, d, latlng, indexBase, index }) => ( {stationPin.map(({ D, d, latlng, indexBase, index }) => (
<MapPin <MapPin
index={index} index={index}
+7 -223
View File
@@ -1,225 +1,9 @@
import React, { useRef, useState, useEffect, useLayoutEffect } from "react"; import type { StyleProp, ViewStyle } from "react-native";
import { View, Text, TouchableOpacity, Linking } from "react-native";
//import MapView from "react-native-maps"; type TrainMenuProps = {
import { useCurrentTrain } from "../stateBox/useCurrentTrain"; style?: StyleProp<ViewStyle>;
import { useNavigation } from "@react-navigation/native"; };
import lineColorList from "../assets/originData/lineColorList";
import { lineListPair, stationIDPair } from "../lib/getStationList"; export default function TrainMenu(_props: TrainMenuProps) {
import { SheetManager } from "react-native-actions-sheet";
import { useTrainMenu } from "../stateBox/useTrainMenu";
//import { MapPin } from "./TrainMenu/MapPin";
import { UsefulBox } from "./TrainMenu/UsefulBox";
import { MapsButton } from "./TrainMenu/MapsButton";
import { useThemeColors } from "@/lib/theme";
export default function TrainMenu({ style }) {
const { fixed } = useThemeColors();
return null; return null;
const { webview } = useCurrentTrain();
const mapRef = useRef();
const { navigate, goBack } = useNavigation();
const [stationPin, setStationPin] = useState([]);
const {
selectedLine,
setSelectedLine,
mapsStationData: stationData,
} = useTrainMenu();
useEffect(() => {
const stationPinData = [];
Object.keys(stationData).forEach((d, indexBase) => {
stationData[d].forEach((D, index) => {
if (!D.StationMap) return null;
if (selectedLine && selectedLine != d) return;
const latlng = D.StationMap.replace(
"https://www.google.co.jp/maps/place/",
""
).split(",");
if (latlng.length == 0) return null;
stationPinData.push({ D, d, latlng, indexBase: 0, index });
});
});
setStationPin(stationPinData);
}, [stationData, selectedLine]);
useLayoutEffect(() => {
mapRef.current.fitToCoordinates(
stationPin.map(({ latlng }) => ({
latitude: parseFloat(latlng[0]),
longitude: parseFloat(latlng[1]),
})),
{ edgePadding: { top: 80, bottom: 120, left: 50, right: 50 } } // Add margin values here
);
}, [stationPin]);
return (
<View style={{ height: "100%", backgroundColor: fixed.primary, ...style }}>
<MapView
style={{ flex: 1, width: "100%", height: "100%" }}
showsUserLocation={true}
loadingEnabled={true}
showsMyLocationButton={false}
moveOnMarkerPress={false}
showsCompass={false}
ref={mapRef}
//provider={PROVIDER_GOOGLE}
initialRegion={{
latitude: 33.774519,
longitude: 133.533306,
latitudeDelta: 1.8, //小さくなるほどズーム
longitudeDelta: 1.8,
}}
>
{stationPin.map(({ D, d, latlng, indexBase, index }) => (
<MapPin
index={index}
indexBase={indexBase}
latlng={latlng}
D={D}
d={d}
navigate={navigate}
webview={webview}
key={D.StationNumber + d}
/>
))}
</MapView>
<View style={{ position: "relative" }}>
<View
style={{
flexDirection: "row",
position: "absolute",
width: "100vw",
bottom: 0,
flex: 1,
}}
>
<TouchableOpacity
style={{
backgroundColor: selectedLine
? lineColorList[stationIDPair[selectedLine]]
: fixed.primary,
padding: 10,
zIndex: 1,
alignItems: "center",
position: "absolute",
bottom: 0,
width: "100%",
paddingBottom: 50,
}}
onPress={() => SheetManager.show("TrainMenuLineSelector")}
>
<Text
style={{
color: "white",
fontWeight: "bold",
fontSize: 10,
textAlign: "center",
}}
>
    
</Text>
<Text
style={{
color: "white",
fontWeight: "bold",
fontSize: 20,
textAlign: "center",
}}
>
{selectedLine
? lineListPair[stationIDPair[selectedLine]]
: "JR四国 対象全駅"}
</Text>
</TouchableOpacity>
<Text style={{ position: "absolute", bottom: 40 }}>
</Text>
{Object.keys(stationData).map((d) => (
<TouchableOpacity
key={stationIDPair[d]}
style={{
flex: 1,
backgroundColor: lineColorList[stationIDPair[d]],
padding: 5,
margin: 2,
borderRadius: 10,
borderColor: "white",
borderWidth: 1,
borderStyle: "solid",
alignItems: "center",
opacity: selectedLine == d ? 1 : !selectedLine ? 1 : 0.5,
zIndex: 10,
}}
onPress={() => {
const s = selectedLine == d ? undefined : d;
if(!s) return;
setSelectedLine(s);
Object.keys(stationData).forEach((data, indexBase) => {
stationData[data].forEach((D, index) => {
if (!D.StationMap) return null;
if (s && s != data) return;
const latlng = D.StationMap.replace(
"https://www.google.co.jp/maps/place/",
""
).split(",");
if (latlng.length == 0) return null;
if (index == 0 && stationPin.length > 0) {
webview.current
?.injectJavaScript(`MoveDisplayStation('${data}_${D.MyStation}_${D.Station_JP}');
document.getElementById("disp").insertAdjacentHTML("afterbegin", "<div />");`);
}
});
});
}}
>
<Text
style={{ color: "white", fontWeight: "bold", fontSize: 20 }}
>
{stationIDPair[d]}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{navigate && (
<View style={{ flexDirection: "row" }}>
<UsefulBox
backgroundColor={"#F89038"}
icon="train-car"
flex={1}
onPressButton={() =>
navigate("howto", {
info: "https://train.jr-shikoku.co.jp/usage.htm",
})
}
>
使
</UsefulBox>
<UsefulBox
backgroundColor={"#EA4752"}
icon="star"
flex={1}
onPressButton={() => navigate("favoriteList")}
>
</UsefulBox>
<UsefulBox
backgroundColor={"#91C31F"}
icon="clipboard-list-outline"
flex={1}
onPressButton={() =>
Linking.openURL(
"https://nexcloud.haruk.in/apps/forms/ZRHjWFF7znr5Xjr2"
)
}
>
</UsefulBox>
</View>
)}
<MapsButton
onPress={() => {
goBack();
}}
top={0}
mapSwitch={"flex"}
/>
</View>
);
} }
+4 -2
View File
@@ -28,6 +28,7 @@ import { getCurrentTrainData } from "@/lib/getCurrentTrainData";
import type { NavigateFunction } from "@/types"; import type { NavigateFunction } from "@/types";
import { PlatformNumber } from "./LED_inside_Component/PlatformNumber"; import { PlatformNumber } from "./LED_inside_Component/PlatformNumber";
import { useThemeColors } from "@/lib/theme"; import { useThemeColors } from "@/lib/theme";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
type Props = { type Props = {
d: eachTrainDiagramType; d: eachTrainDiagramType;
@@ -48,6 +49,7 @@ export const EachData: FC<Props> = (props) => {
} = props; } = props;
const { currentTrain } = useCurrentTrain(); const { currentTrain } = useCurrentTrain();
const { stationList } = useStationList(); const { stationList } = useStationList();
const { playbackCurrentTimeIso } = useTrainMenu();
const { allCustomTrainData } = useAllTrainDiagram(); const { allCustomTrainData } = useAllTrainDiagram();
const { fixed } = useThemeColors(); const { fixed } = useThemeColors();
const openTrainInfo = (d: { const openTrainInfo = (d: {
@@ -134,7 +136,7 @@ export const EachData: FC<Props> = (props) => {
const [h, m] = d.time.split(":"); const [h, m] = d.time.split(":");
const IntH = parseInt(h); const IntH = parseInt(h);
const IntM = parseInt(m); const IntM = parseInt(m);
const currentTime = dayjs(); const currentTime = playbackCurrentTimeIso ? dayjs(playbackCurrentTimeIso) : dayjs();
const trainTime = currentTime const trainTime = currentTime
.set("hour", IntH < 4 ? IntH + 24 : IntH) .set("hour", IntH < 4 ? IntH + 24 : IntH)
.set("minute", IntM); .set("minute", IntM);
@@ -145,7 +147,7 @@ export const EachData: FC<Props> = (props) => {
setIsDepartureNow(false); setIsDepartureNow(false);
setIsShow(true); setIsShow(true);
}; };
}, [d.time, currentTrainData]); }, [d.time, currentTrainData, playbackCurrentTimeIso]);
useInterval(() => { useInterval(() => {
if (isDepartureNow) { if (isDepartureNow) {
setIsShow(!isShow); setIsShow(!isShow);
@@ -3,11 +3,8 @@ import { Text, View, LayoutChangeEvent } from "react-native";
import Animated, { import Animated, {
useSharedValue, useSharedValue,
useAnimatedStyle, useAnimatedStyle,
withTiming,
withRepeat,
withSequence,
withDelay,
cancelAnimation, cancelAnimation,
useFrameCallback,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useThemeColors } from "@/lib/theme"; import { useThemeColors } from "@/lib/theme";
@@ -18,40 +15,65 @@ type Props = {
export const ScrollingDescription: FC<Props> = ({ description }) => { export const ScrollingDescription: FC<Props> = ({ description }) => {
const { fixed } = useThemeColors(); const { fixed } = useThemeColors();
const scrollX = useSharedValue(0); const scrollX = useSharedValue(0);
const isRunning = useSharedValue(false);
const contentWidth = useSharedValue(0);
const viewportWidth = useSharedValue(0);
const [textWidth, setTextWidth] = useState(0); const [textWidth, setTextWidth] = useState(0);
const [containerWidth, setContainerWidth] = useState(0); const [containerWidth, setContainerWidth] = useState(0);
const gap = 20;
const pixelsPerSecond = 84;
// 改行を削除して1行にする // 改行を削除して1行にする
const singleLineDescription = description?.replace(/\n/g, " ") || ""; const singleLineDescription = description?.replace(/\n/g, " ") || "";
useEffect(() => { useEffect(() => {
cancelAnimation(scrollX);
contentWidth.value = textWidth;
viewportWidth.value = containerWidth;
if (!singleLineDescription || textWidth === 0 || containerWidth === 0) { if (!singleLineDescription || textWidth === 0 || containerWidth === 0) {
isRunning.value = false;
scrollX.value = 0;
return; return;
} }
// テキストが画面幅より短い場合はスクロールしない // テキストが画面幅より短い場合はスクロールしない
if (textWidth <= containerWidth) { if (textWidth <= containerWidth) {
isRunning.value = false;
scrollX.value = 0;
return; return;
} }
const distance = textWidth + containerWidth;
const duration = distance * 6; // スクロール速度
// 初期位置を設定(画面の右端から開始)
scrollX.value = containerWidth; scrollX.value = containerWidth;
isRunning.value = true;
scrollX.value = withRepeat(
withSequence(
withDelay(500, withTiming(-textWidth - 20, { duration })),
withDelay(500, withTiming(containerWidth, { duration: 0 }))
),
-1
);
return () => { return () => {
isRunning.value = false;
cancelAnimation(scrollX); cancelAnimation(scrollX);
}; };
}, [singleLineDescription, textWidth, containerWidth]); }, [
singleLineDescription,
textWidth,
containerWidth,
scrollX,
isRunning,
contentWidth,
viewportWidth,
]);
useFrameCallback((frameInfo) => {
"worklet";
if (!isRunning.value) {
return;
}
const elapsedMs = frameInfo.timeSincePreviousFrame ?? 16.67;
const nextX = scrollX.value - (pixelsPerSecond * elapsedMs) / 1000;
const resetPoint = -contentWidth.value - gap;
scrollX.value = nextX <= resetPoint ? viewportWidth.value : nextX;
});
const animatedStyle = useAnimatedStyle(() => ({ const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: scrollX.value }], transform: [{ translateX: scrollX.value }],
@@ -60,14 +82,20 @@ export const ScrollingDescription: FC<Props> = ({ description }) => {
const handleTextLayout = (event: LayoutChangeEvent) => { const handleTextLayout = (event: LayoutChangeEvent) => {
const { width } = event.nativeEvent.layout; const { width } = event.nativeEvent.layout;
if (width > 0) { if (width > 0) {
setTextWidth(Math.round(width)); const nextWidth = Math.round(width);
setTextWidth((currentWidth) =>
currentWidth === nextWidth ? currentWidth : nextWidth
);
} }
}; };
const handleContainerLayout = (event: LayoutChangeEvent) => { const handleContainerLayout = (event: LayoutChangeEvent) => {
const { width } = event.nativeEvent.layout; const { width } = event.nativeEvent.layout;
if (width > 0) { if (width > 0) {
setContainerWidth(Math.round(width)); const nextWidth = Math.round(width);
setContainerWidth((currentWidth) =>
currentWidth === nextWidth ? currentWidth : nextWidth
);
} }
}; };
+462 -15
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, FC } from "react"; import React, { useState, useEffect, FC, useCallback, useRef } from "react";
import { View, useWindowDimensions, Text } from "react-native"; import { View, useWindowDimensions, Text, Platform } from "react-native";
import { objectIsEmpty } from "@/lib/objectIsEmpty"; import { objectIsEmpty } from "@/lib/objectIsEmpty";
import { useCurrentTrain } from "@/stateBox/useCurrentTrain"; import { useCurrentTrain } from "@/stateBox/useCurrentTrain";
import { useAreaInfo } from "@/stateBox/useAreaInfo"; import { useAreaInfo } from "@/stateBox/useAreaInfo";
@@ -13,7 +13,40 @@ import { getTime, trainTimeFiltering } from "@/lib/trainTimeFiltering";
import { eachTrainDiagramType, StationProps } from "@/lib/CommonTypes"; import { eachTrainDiagramType, StationProps } from "@/lib/CommonTypes";
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from "@react-navigation/native";
import { useThemeColors } from "@/lib/theme"; import { useThemeColors } from "@/lib/theme";
import { getCurrentTrainData } from "@/lib/getCurrentTrainData";
import {
useAudioPlayer,
useAudioPlayerStatus,
setAudioModeAsync,
} from "expo-audio";
import { useInterval } from "@/lib/useInterval";
import {
buildVoicepeakAnnouncementKey,
buildVoicepeakAnnouncementText,
getVoicepeakAnnouncementStage,
hasVoicepeakConfiguration,
loadVoicepeakSettings,
requestVoicepeakSpeeches,
type VoicepeakSettings,
} from "@/lib/voicepeak";
import {
EMPTY_NATIVE_VOICEPEAK_HTML,
type PreparedVoicepeakAudio,
} from "@/lib/voicepeakAudioSource";
import { WebView } from "react-native-webview";
import { stackAwareNavigate } from "@/lib/rootNavigation"; import { stackAwareNavigate } from "@/lib/rootNavigation";
import { useStationList } from "@/stateBox/useStationList";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { checkDuplicateTrainData } from "@/lib/checkDuplicateTrainData";
import { trainPosition } from "@/lib/trainPositionTextArray";
const readBooleanSetting = async (key: string) => {
try {
return (await AS.getItem(key)) === "true";
} catch {
return false;
}
};
/** /**
* *
@@ -47,11 +80,38 @@ import { stackAwareNavigate } from "@/lib/rootNavigation";
type props = { type props = {
station: StationProps[]; station: StationProps[];
}; };
type VoicepeakCandidate = {
key: string;
text: string;
departureTime: string;
priority: number;
};
const getServiceMinute = (timeText: string) => {
const [hourText, minuteText] = timeText.split(":");
const hour = Number.parseInt(hourText, 10);
const minute = Number.parseInt(minuteText, 10);
if (Number.isNaN(hour) || Number.isNaN(minute)) return null;
return (hour < 4 ? hour + 24 : hour) * 60 + minute;
};
const getDwellMinutes = (arrivalTime: string, departureTime: string) => {
const arrivalMinute = getServiceMinute(arrivalTime);
const departureMinute = getServiceMinute(departureTime);
if (arrivalMinute === null || departureMinute === null) return null;
const difference = departureMinute - arrivalMinute;
return difference < 0 ? difference + 24 * 60 : difference;
};
export const LED_vision: FC<props> = (props) => { export const LED_vision: FC<props> = (props) => {
const { station } = props; const { station } = props;
const { navigate } = useNavigation(); const { navigate, addListener } = useNavigation();
const { currentTrain } = useCurrentTrain(); const { currentTrain } = useCurrentTrain();
const { stationList } = useStationList();
const { playbackCurrentTimeIso } = useTrainMenu();
const [stationDiagram, setStationDiagram] = useState<{ const [stationDiagram, setStationDiagram] = useState<{
[key: string]: string; [key: string]: string;
}>({}); //当該駅の全時刻表 }>({}); //当該駅の全時刻表
@@ -60,20 +120,75 @@ export const LED_vision: FC<props> = (props) => {
const [trainDescriptionSwitch, setTrainDescriptionSwitch] = useState(false); const [trainDescriptionSwitch, setTrainDescriptionSwitch] = useState(false);
const [isInfoArea, setIsInfoArea] = useState(false); const [isInfoArea, setIsInfoArea] = useState(false);
const { areaInfo, areaStationID } = useAreaInfo(); const { areaInfo, areaStationID } = useAreaInfo();
const { allTrainDiagram } = useAllTrainDiagram(); const { allTrainDiagram, allCustomTrainData } = useAllTrainDiagram();
const { fixed } = useThemeColors(); const { fixed } = useThemeColors();
const [voicepeakSettings, setVoicepeakSettings] =
useState<VoicepeakSettings | null>(null);
const announcementPlayer = useAudioPlayer(null);
const announcementPlayerStatus = useAudioPlayerStatus(announcementPlayer);
const announcedKeysRef = useRef<Set<string>>(new Set());
const reservedAnnouncementKeysRef = useRef<Set<string>>(new Set());
const queuedAnnouncementsRef = useRef<Map<string, VoicepeakCandidate>>(
new Map()
);
const isVoicepeakBusyRef = useRef(false);
const isExpoVoicepeakPlayingRef = useRef(false);
const isNativeVoicepeakPlayingRef = useRef(false);
const drainVoicepeakQueueRef = useRef<() => void>(() => {});
const isVoicepeakMountedRef = useRef(true);
const currentRequestRef = useRef<AbortController | null>(null);
const cleanupAudioRef = useRef<(() => void) | undefined>(undefined);
const pendingVoicepeakAudiosRef = useRef<PreparedVoicepeakAudio[]>([]);
const startVoicepeakAudioRef = useRef<
(audio: PreparedVoicepeakAudio) => Promise<void>
>(async () => {});
const [nativeVoicepeakHtml, setNativeVoicepeakHtml] = useState(
EMPTY_NATIVE_VOICEPEAK_HTML
);
const [nativeVoicepeakPlaybackKey, setNativeVoicepeakPlaybackKey] = useState(0);
const refreshVoicepeakSettings = useCallback(() => {
loadVoicepeakSettings()
.then((settings) => {
setVoicepeakSettings(settings);
})
.catch((error) => {
console.warn("Failed to load Voicepeak settings", error);
});
}, []);
useEffect(() => { useEffect(() => {
AS.getItem("LEDSettings/trainIDSwitch").then((data) => { isVoicepeakMountedRef.current = true;
setTrainIDSwitch(data === "true"); void Promise.all([
readBooleanSetting("LEDSettings/trainIDSwitch"),
readBooleanSetting("LEDSettings/trainDescriptionSwitch"),
readBooleanSetting("LEDSettings/finalSwitch"),
]).then(([nextTrainIdSwitch, nextTrainDescriptionSwitch, nextFinalSwitch]) => {
setTrainIDSwitch(nextTrainIdSwitch);
setTrainDescriptionSwitch(nextTrainDescriptionSwitch);
setFinalSwitch(nextFinalSwitch);
}); });
AS.getItem("LEDSettings/trainDescriptionSwitch").then((data) => {
setTrainDescriptionSwitch(data === "true"); refreshVoicepeakSettings();
});
AS.getItem("LEDSettings/finalSwitch").then((data) => { const unsubscribe = addListener("focus", refreshVoicepeakSettings);
setFinalSwitch(data === "true");
}); return () => {
}, []); isVoicepeakMountedRef.current = false;
unsubscribe();
currentRequestRef.current?.abort();
cleanupAudioRef.current?.();
cleanupAudioRef.current = undefined;
pendingVoicepeakAudiosRef.current.forEach((audio) => {
if (audio.kind === "expo-audio") audio.cleanup?.();
});
pendingVoicepeakAudiosRef.current = [];
queuedAnnouncementsRef.current.clear();
reservedAnnouncementKeysRef.current.clear();
isVoicepeakBusyRef.current = false;
};
}, [addListener, refreshVoicepeakSettings]);
useEffect(() => { useEffect(() => {
// 現在の駅に停車するダイヤを作成する副作用[列車ダイヤと現在駅情報] // 現在の駅に停車するダイヤを作成する副作用[列車ダイヤと現在駅情報]
@@ -113,10 +228,316 @@ export const LED_vision: FC<props> = (props) => {
if (!currentTrain) return () => {}; if (!currentTrain) return () => {};
const data = trainTimeAndNumber const data = trainTimeAndNumber
.filter((d) => currentTrain.map((m) => m.num).includes(d.train)) //現在の列車に絞る[ToDo] .filter((d) => currentTrain.map((m) => m.num).includes(d.train)) //現在の列車に絞る[ToDo]
.filter((d) => trainTimeFiltering({ d, currentTrain, station })) //時間フィルター .filter((d) => trainTimeFiltering({ d, currentTrain, station, stationList, now: playbackCurrentTimeIso })) //時間フィルター
.filter((d) => !!finalSwitch || d.lastStation != station[0].Station_JP); //最終列車表示設定 .filter((d) => !!finalSwitch || d.lastStation != station[0].Station_JP); //最終列車表示設定
setSelectedTrain(data); setSelectedTrain(data);
}, [trainTimeAndNumber, currentTrain, finalSwitch]); }, [trainTimeAndNumber, currentTrain, finalSwitch, stationList, playbackCurrentTimeIso]);
const getVoicepeakCandidates = useCallback(() => {
if (!currentTrain?.length || !allCustomTrainData) return [];
const activeTrainNumbers = new Set(currentTrain.map((train) => train.num));
const candidateTrains = new Map(
selectedTrain.map((train) => [train.train, train])
);
trainTimeAndNumber.forEach((train) => {
if (train.isThrough && activeTrainNumbers.has(train.train)) {
candidateTrains.set(train.train, train);
}
});
return [...candidateTrains.values()]
.flatMap((train) => {
const currentTrainData = getCurrentTrainData(
train.train,
currentTrain,
allCustomTrainData
);
const currentTrainStatuses = currentTrain.filter(
(currentTrainItem) => currentTrainItem.num === train.train
);
const currentTrainStatus =
currentTrainStatuses.length > 1
? checkDuplicateTrainData(currentTrainStatuses, stationList) ??
currentTrainStatuses[0]
: currentTrainStatuses[0];
const position = currentTrainStatus
? trainPosition(currentTrainStatus)
: null;
const isStoppedAtStation =
position?.isBetween === false &&
position.Pos.Pos === station[0].Station_JP;
const delayMinutes =
typeof currentTrainStatus?.delay === "number"
? currentTrainStatus.delay
: 0;
if (!currentTrainData) {
return [];
}
const getStage = (timingTrain: eachTrainDiagramType) =>
getVoicepeakAnnouncementStage({
station: station[0],
train: timingTrain,
currentTrainData,
delayMinutes,
});
const buildCandidate = (
timingTrain: eachTrainDiagramType,
stage: NonNullable<ReturnType<typeof getVoicepeakAnnouncementStage>>,
advanceTimeBasis: "arrival" | "departure" = "departure"
): VoicepeakCandidate => ({
key: buildVoicepeakAnnouncementKey(station[0], timingTrain, stage),
text: buildVoicepeakAnnouncementText({
station: station[0],
train: timingTrain,
currentTrainData,
stage,
delayMinutes,
isOrigin: timingTrain.isOrigin === true,
isStoppedAtStation,
advanceTimeBasis,
}),
departureTime: timingTrain.time,
priority:
stage === "passing" ? 0 : stage === "departure" ? 1 : 2,
});
if (train.arrivalTime && train.departureTime) {
const candidates: VoicepeakCandidate[] = [];
const arrivalTimingTrain = { ...train, time: train.arrivalTime };
const arrivalStage = getStage(arrivalTimingTrain);
if (arrivalStage === "advance" || arrivalStage === "departure") {
candidates.push(
buildCandidate(arrivalTimingTrain, "advance", "arrival")
);
}
const departureTimingTrain = { ...train, time: train.departureTime };
const departureStage = getStage(departureTimingTrain);
const dwellMinutes = getDwellMinutes(
train.arrivalTime,
train.departureTime
);
if (departureStage === "departure") {
candidates.push(
buildCandidate(departureTimingTrain, "departure")
);
} else if (
departureStage === "advance" &&
dwellMinutes !== null &&
dwellMinutes >= 3
) {
candidates.push(buildCandidate(departureTimingTrain, "advance"));
}
return candidates;
}
const stage = getStage(train);
return stage ? [buildCandidate(train, stage)] : [];
})
.sort(
(a, b) =>
a.priority - b.priority ||
a.departureTime.localeCompare(b.departureTime)
);
}, [
allCustomTrainData,
currentTrain,
selectedTrain,
station,
stationList,
trainTimeAndNumber,
]);
const finishVoicepeakPlayback = useCallback(() => {
isExpoVoicepeakPlayingRef.current = false;
isNativeVoicepeakPlayingRef.current = false;
cleanupAudioRef.current?.();
cleanupAudioRef.current = undefined;
pendingVoicepeakAudiosRef.current.forEach((audio) => {
if (audio.kind === "expo-audio") audio.cleanup?.();
});
pendingVoicepeakAudiosRef.current = [];
isVoicepeakBusyRef.current = false;
if (isVoicepeakMountedRef.current) {
drainVoicepeakQueueRef.current();
}
}, []);
const startVoicepeakAudio = useCallback(
async (audio: PreparedVoicepeakAudio) => {
cleanupAudioRef.current?.();
cleanupAudioRef.current =
audio.kind === "expo-audio" ? audio.cleanup : undefined;
await setAudioModeAsync({
playsInSilentMode: true,
shouldPlayInBackground: false,
interruptionMode: "duckOthers",
});
if (audio.kind === "native-webview") {
isNativeVoicepeakPlayingRef.current = true;
setNativeVoicepeakHtml(audio.html);
setNativeVoicepeakPlaybackKey((current) => current + 1);
} else {
announcementPlayer.replace(audio.source);
announcementPlayer.volume = 1;
await announcementPlayer.seekTo(0);
isExpoVoicepeakPlayingRef.current = true;
announcementPlayer.play();
}
},
[announcementPlayer]
);
useEffect(() => {
startVoicepeakAudioRef.current = startVoicepeakAudio;
}, [startVoicepeakAudio]);
const completeVoicepeakAudioSegment = useCallback(() => {
isExpoVoicepeakPlayingRef.current = false;
isNativeVoicepeakPlayingRef.current = false;
cleanupAudioRef.current?.();
cleanupAudioRef.current = undefined;
const nextAudio = pendingVoicepeakAudiosRef.current.shift();
if (!nextAudio || !isVoicepeakMountedRef.current) {
finishVoicepeakPlayback();
return;
}
void startVoicepeakAudioRef.current(nextAudio).catch((error) => {
console.warn("Failed to play Voicepeak announcement segment", error);
finishVoicepeakPlayback();
});
}, [finishVoicepeakPlayback]);
const playVoicepeakAnnouncementBatch = useCallback(
async (candidates: VoicepeakCandidate[]) => {
if (!voicepeakSettings || !hasVoicepeakConfiguration(voicepeakSettings)) {
candidates.forEach((candidate) => {
reservedAnnouncementKeysRef.current.delete(candidate.key);
});
isVoicepeakBusyRef.current = false;
return;
}
const combinedText = candidates
.map((candidate) => candidate.text)
.join("続いて、");
const controller = new AbortController();
currentRequestRef.current = controller;
try {
const audios = await requestVoicepeakSpeeches({
text: combinedText,
settings: voicepeakSettings,
signal: controller.signal,
});
if (!isVoicepeakMountedRef.current) {
audios.forEach((audio) => {
if (audio.kind === "expo-audio") audio.cleanup?.();
});
return;
}
const [firstAudio, ...remainingAudios] = audios;
if (!firstAudio) {
throw new Error("Voicepeak returned no announcement audio");
}
pendingVoicepeakAudiosRef.current = remainingAudios;
await startVoicepeakAudio(firstAudio);
candidates.forEach((candidate) => {
announcedKeysRef.current.add(candidate.key);
reservedAnnouncementKeysRef.current.delete(candidate.key);
});
} catch (error) {
candidates.forEach((candidate) => {
reservedAnnouncementKeysRef.current.delete(candidate.key);
if (!controller.signal.aborted) {
announcedKeysRef.current.add(candidate.key);
}
});
if (!controller.signal.aborted) {
console.warn("Failed to play Voicepeak announcement", error);
}
finishVoicepeakPlayback();
} finally {
if (currentRequestRef.current === controller) {
currentRequestRef.current = null;
}
}
},
[finishVoicepeakPlayback, startVoicepeakAudio, voicepeakSettings]
);
const drainVoicepeakQueue = useCallback(() => {
if (
isVoicepeakBusyRef.current ||
queuedAnnouncementsRef.current.size === 0
) {
return;
}
const candidates = [...queuedAnnouncementsRef.current.values()].sort(
(a, b) =>
a.priority - b.priority ||
a.departureTime.localeCompare(b.departureTime)
);
queuedAnnouncementsRef.current.clear();
isVoicepeakBusyRef.current = true;
void playVoicepeakAnnouncementBatch(candidates);
}, [playVoicepeakAnnouncementBatch]);
useEffect(() => {
drainVoicepeakQueueRef.current = drainVoicepeakQueue;
}, [drainVoicepeakQueue]);
useEffect(() => {
if (
isExpoVoicepeakPlayingRef.current &&
announcementPlayerStatus.didJustFinish
) {
completeVoicepeakAudioSegment();
}
}, [
announcementPlayerStatus.didJustFinish,
completeVoicepeakAudioSegment,
]);
const checkVoicepeakAnnouncement = useCallback(() => {
if (!voicepeakSettings || !hasVoicepeakConfiguration(voicepeakSettings)) {
return;
}
getVoicepeakCandidates().forEach((candidate) => {
if (
announcedKeysRef.current.has(candidate.key) ||
reservedAnnouncementKeysRef.current.has(candidate.key)
) {
return;
}
reservedAnnouncementKeysRef.current.add(candidate.key);
queuedAnnouncementsRef.current.set(candidate.key, candidate);
});
drainVoicepeakQueue();
}, [drainVoicepeakQueue, getVoicepeakCandidates, voicepeakSettings]);
useEffect(() => {
checkVoicepeakAnnouncement();
}, [checkVoicepeakAnnouncement]);
useInterval(() => {
checkVoicepeakAnnouncement();
}, 1000);
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const adjustedWidth = width * 0.98; const adjustedWidth = width * 0.98;
@@ -130,6 +551,32 @@ export const LED_vision: FC<props> = (props) => {
marginHorizontal: width * 0.01, marginHorizontal: width * 0.01,
}} }}
> >
{Platform.OS !== "web" && (
<WebView
key={nativeVoicepeakPlaybackKey}
source={{ html: nativeVoicepeakHtml }}
originWhitelist={["*"]}
javaScriptEnabled
scrollEnabled={false}
mediaPlaybackRequiresUserAction={false}
allowsInlineMediaPlayback
onMessage={(event) => {
const message = event.nativeEvent.data;
if (
isNativeVoicepeakPlayingRef.current &&
(message === "voicepeak-ended" || message === "voicepeak-error")
) {
completeVoicepeakAudioSegment();
}
}}
style={{
position: "absolute",
width: 1,
height: 1,
opacity: 0,
}}
/>
)}
<Header station={station[0]} /> <Header station={station[0]} />
<View <View
+18
View File
@@ -109,6 +109,24 @@ export const STORAGE_KEYS = {
/** 駅固定モード遅延速報案内機能(サウンド) */ /** 駅固定モード遅延速報案内機能(サウンド) */
SOUND_DELAY_ANNOUNCEMENT: 'soundDelayAnnouncement', SOUND_DELAY_ANNOUNCEMENT: 'soundDelayAnnouncement',
/** Voicepeak 発車案内機能の有効化スイッチ */
VOICEPEAK_ENABLED: 'voicepeakEnabled',
/** 列車追従中のバックグラウンドりっかちゃん駅接近案内 */
BACKGROUND_RIKKA_ANNOUNCEMENT: 'backgroundRikkaAnnouncement',
/** りっかちゃん駅接近案内の判定元 ("deviceLocation" | "trainPosition") */
BACKGROUND_RIKKA_TRIGGER_SOURCE: 'backgroundRikkaTriggerSource',
/** Voicepeak API ベースURL */
VOICEPEAK_BASE_URL: 'voicepeakBaseUrl',
/** Voicepeak API トークン */
VOICEPEAK_API_TOKEN: 'voicepeakApiToken',
/** Voicepeak 話者名 */
VOICEPEAK_SPEAKER: 'voicepeakSpeaker',
/** カラーテーマ設定 ("light" | "system" | "dark") */ /** カラーテーマ設定 ("light" | "system" | "dark") */
COLOR_THEME: 'colorTheme', COLOR_THEME: 'colorTheme',
@@ -0,0 +1,591 @@
# API構成・通信負荷レビュー 2026-07-29
## 結論
現状の重さは、単純な「APIレスポンスが遅い」だけではなく、次の4点が重なって発生している。
1. 同じ大容量データをReact Native側と走行位置WebView側が別々に30秒ごとに取得している。
2. 起動時は通常ポーリングとは別に、同じ取得が短時間に2回以上発生する経路がある。
3. 更新がなくても全件JSONを再取得し、展開・JSON parse・全件走査・JSON stringify・state更新を繰り返している。
4. n8n、Google Apps Script、静的ストレージ、バックエンドAPI、公式Webサイトへ端末が直接接続し、キャッシュ・fallback・データ結合を各端末側で担当している。
最優先は、バックエンドを単なるデータ取得先ではなく「端末向けBFFBackend for Frontend)」にして、データ取得の所有者をReact Native側の1か所へ集約すること。そのうえで、WebViewには取得済みデータを注入する。
バックエンド側では、データの更新頻度を以下の3階層に分けるのがよい。
- live: 走行位置、運行情報サマリー。5〜15秒単位、差分または304。
- operational: 運行ログ、外部運用情報。30〜60秒単位、差分または対象列番だけ。
- daily/static: 当日ダイヤ、列車マスタ、駅マスタ。バージョンが変わったときだけ取得。
この構成にすると、アプリは「複数hostの取得・fallback・結合・キャッシュ」を持たず、`live snapshot``versioned resources` を表示するだけになる。
---
## 調査範囲
- `App.tsx` の全Provider
- `stateBox` 配下のデータ取得
- `lib/webViewInjectjavascript.ts` のWebView内fetchとlocalStorage cache
- 走行位置・運行情報の起動時prewarm
- 列車詳細、発車標、駅詳細、Android Widgetの個別fetch
- API endpoint、ポーリング間隔、timeout、retry、fallback
- 2026-07-29時点の公開endpointに対するGET実測
今回変更したのは本レポートだけで、アプリ実装は変更していない。
---
## 現在の通信構造
```text
アプリ起動
├─ React Native Providers
│ ├─ n8n: current positions15秒)
│ ├─ backend-api: train-data30秒)
│ ├─ backend-api: operation-logs30秒)
│ ├─ data-storage: diagram-today30秒)
│ ├─ n8n + GAS: operation flag/text60秒)
│ ├─ GAS: delay, train-pair, bus/train
│ └─ backend-api: permission
├─ 走行位置WebView
│ ├─ JR四国公式ページ/XHR
│ ├─ backend-api: train-data30秒)
│ ├─ backend-api: operation-logs30秒)
│ ├─ data-storage: diagram-today30秒)
│ ├─ n8n: station-list / position-problems
│ └─ data-storage: unyohub / elesite(有効時30秒)
└─ 運行情報WebView
└─ JR四国公式ページ
```
走行位置WebViewが表示タブでなくても保持されるため、React Native Providerの通信とWebViewの通信が同時に継続する。
---
## 実測したレスポンス
2026-07-29 10:39〜10:42 UTCに1回ずつ計測した参考値。時間はネットワーク状況で変動する。
| データ | endpoint | JSON/本文サイズ | zstd交渉時の転送量 | 件数 | 参考応答時間 |
|---|---|---:|---:|---:|---:|
| 列車マスタ/カスタム列車 | `/train-data` | 1,218,990 B | 72,498 B | 1,851 | 0.12〜0.21秒 |
| 当日ダイヤ | `/tmp/diagram-today.json` | 667,584 B | 120,278 B | 1,333 | 0.16〜0.52秒 |
| 運行ログ | `/operation-logs` | 64,162 B | 9,508 B | 115 | 0.08秒 |
| 現在位置 | n8n positions | 13,887 B | 2,328 B | 105 | 0.14〜0.46秒 |
| 駅リスト | n8n station-list | 82,561 B | 11,389 B | 9路線 | 0.17〜0.49秒 |
| UnyoHub | static JSON | 957,331 B | 62,048 B | 220 | 0.16〜0.56秒 |
| えれサイト | static JSON | 867,937 B | 29,588 B | 374 | 0.14〜0.59秒 |
| 運行情報本文 | GAS | 小容量 | 20 B(今回) | - | 2.16秒 |
| 遅延情報本文 | GAS | 小容量 | 20 B(今回) | - | 1.70秒 |
| バス・列車データ | GAS | 小容量 | 1,436 B | - | 2.52秒 |
| 列車ペア | GAS | 小容量 | 512 B | - | 1.68秒 |
### HTTP cacheの状態
- Cloudflare圧縮は有効であり、転送時の圧縮不足は主問題ではない。
- 今回確認した主要endpointには `Cache-Control` がなかった。
- `/train-data``/operation-logs``cf-cache-status: DYNAMIC` で、確認したレスポンスにはETagがなかった。
- static JSONにはETag/Last-Modifiedがあるが、`cf-cache-status: DYNAMIC` だった。
- UnyoHub/えれサイトはクライアントが毎回 `?_=timestamp` を付けるため、ETagや通常のHTTP cacheを実質利用できない。
圧縮後の転送量が小さくても、端末では毎回1.2MB等へ展開してJSON parseする。WebView側はさらに全体を `JSON.stringify` して変更判定とlocalStorage保存を行う。
---
## 概算負荷
### 通常時
走行位置WebViewが一度起動して保持されている前提では、主要3データだけで以下が継続する。
| 実行場所 | 30秒ごとの対象 | 1分あたりの展開後JSON |
|---|---|---:|
| React Native | train-data + diagram + operation-logs | 約3.90MB |
| WebView | train-data + diagram + operation-logs | 約3.90MB |
| React Native | positions15秒) | 約0.06MB |
| 合計 | - | 約7.86MB/分 |
1時間表示すると、変更有無に関係なく約472MB分のJSONを両JS runtimeで処理する計算になる。zstd相当の圧縮が使われた場合でも、参考転送量は約0.82MB/分、約49MB/時。
UnyoHubとえれサイトを両方有効にすると、WebViewだけでさらに約3.65MB/分の展開・parseが増える。参考転送量は約0.18MB/分増える。React Native側の同名hookが複数mountされると、その分も追加される。
### 起動時
トップメニュー起動から走行位置WebViewの初期化までに、主要JSONだけで概算約6MBが展開・parseされる。これには公式WebViewのHTML、CSS、JavaScript、画像、公式XHR、GAS、n8nの補助APIは含めていない。
理由:
- `getCurrentTrain()` は初回effectと `mockApiFeatureEnabled` effectの両方がmount時に走る。
- WebViewのPhase 1/2で主要APIを取得した直後、`startPolling()` が初回待機なしで同じAPIを再取得する。
- React Native Providerも同じ主要APIを取得済み。
---
## 無駄な通信と判断した要素
### P0: React NativeとWebViewの二重取得
対象:
- `/train-data`
- `/operation-logs`
- `/tmp/diagram-today.json`
React Native側は `stateBox/useAllTrainDiagram.tsx` で30秒ごと、WebView側は `lib/webViewInjectjavascript.ts` で30秒ごとに取得する。
同じアプリプロセス内にWebView bridgeがあるため、通信の所有者をReact Native側に統一し、WebViewへ差分注入できる。これだけで主要3データの通信・parse・hash処理をほぼ半減できる。
### P0: WebView初期取得直後の即時再取得
WebViewはPhase 1/2で以下を取得する。
- station-list
- operation-logs
- position-problems
- train-data
- diagram-today
その完了直後に `startPolling()` を呼び、その中で初回fetchを即実行するため、station-list以外の4データを短時間にもう一度取得する。
ポーリング開始時は最初の30秒を待つか、Phase 1/2の取得時刻を初回ポーリング成功として扱うべき。
### P0: 起動時positionsの二重取得
`stateBox/useCurrentTrain.tsx` は以下の2 effectがmount時に両方実行される。
- dependency `[]` の初回取得
- dependency `[mockApiFeatureEnabled]` の切替時取得
結果として通常位置情報を2本同時に開始し得る。n8n失敗時は各リクエストがretry後にGAS fallbackへ進むため、不調時に通信が増幅する。
### P0: 非表示タブのWebView prewarm重複
トップメニュー起動時は、1pxのhidden positions/operation WebViewを読み込む。同時に500ms後にはpositions/information root自体もprewarmされ、実WebViewをmountする。
hidden WebViewの読み込みが完了前に実WebViewへ切り替わると、公式ページの初期通信を2回行ったうえ、hidden側のwarm-up結果を実WebView instanceへ引き継げない。
hidden preloadと実root preloadのどちらか一方に統一すべき。
### P0: 全件データを更新有無に関係なく30秒取得
特に `/train-data` は1,851件、約1.22MB。多くの `updated_at` は分単位で変わる性質ではないが、30秒ごとに全件取得している。
`diagram-today` も当日中の変更頻度に対して30秒全件取得は過剰。ETag/If-None-Match、バージョンmanifest、immutable URLのいずれかが必要。
### P0: UnyoHub/えれサイトの多重ポーラー
`useUnyohub()``useElesite()` は共有Contextではなく、hookを呼ぶコンポーネントごとに独立state・独立intervalを作る。
確認できた呼び出し箇所:
- StationDiagramView
- StationDiagram/ListView
- TrainDataSources
- EachTrainInfoCore/HeaderText
さらにWebViewも同じデータを30秒ごとに取得する。React Native hookは10分間隔だが、mount数に比例して増える。
両データは約0.96MB、約0.87MBあるため、1つのProvider/BFF queryへ集約する必要がある。
### P0: cache busterによるHTTP cache無効化
UnyoHub/えれサイトはReact Native/WebViewの両方で `?_=Date.now()` を付ける。static JSONにはETagがあるため、これは既存のcache能力を捨てている。
更新確認には `If-None-Match` またはversion manifestを使うべきで、timestamp queryは削除対象。
### P1: retry時間がpoll間隔を超え、リクエストが重なる
React Nativeの主要fetchはtimeout 15秒、最大1 retry、retry前wait 0.75〜1.25秒。最悪約31秒かかる一方、poll間隔は30秒。
positionsはtimeout 8秒 + retryで最悪約17秒、poll間隔は15秒。
`useInterval` にin-flight guardがないため、不調時に次周期が開始される。バックエンドが遅いほど端末とサーバー双方へ追加負荷をかける。
### P1: 表示詳細ごとのn8n GET
位置ID補助情報は少なくとも以下2 componentから同じendpointへ取得される。
- `TrainDataView`
- LED `TrainPosition`
`TrainDataView``currentTrainData` object全体をdependencyにしているため、15秒ごとのpositions更新で同じ位置でも再取得し得る。
このメタデータはpositionsレスポンスへ結合するか、BFF側の `position_meta_by_key` として一括配信する方がよい。
### P1: アンパンマン列車statusの再取得
`TrainIconStatus` のn8n fetchは、列番だけでなく `allCustomTrainData``todayOperation``iconDisplayMode` の変更でも再実行される。前2つは30秒ごとに新しい配列へ置き換わる。
同じ列番を複数rowで表示する場合も各componentが個別取得する。日次列車メタデータかlive train contextへstatusを含めるべき。
### P1: 小容量だが遅いGASへの直接接続
今回の実測ではGASの小容量responseに1.7〜2.5秒かかった。
- operation text
- delay text
- bus/train
- train pair
- positions fallback
データ量の問題ではなく、接続先と実行基盤の応答時間が支配的。バックエンドがGASを定期取得・cacheし、端末には同一originから即時返す方がよい。
### P2: 駅住所の外部LOD API取得
駅詳細表示時に `jslodApi.json` を直接取得する。駅住所は更新頻度が極めて低いため、駅マスタへ含めるかアプリassetにするべき。
### P2: 使用されていないendpoint定数
`constants/api.ts` の以下は現在のアプリコードから参照されていない。
- `CUSTOM_TRAIN_DATA`
- `DELAY_INFO`
- `SPECIAL_TRAIN_INFO`
旧endpointを残すならdeprecated表示と削除予定を明記し、そうでなければ削除してAPI inventoryを単純化する。
---
## バックエンド側で簡略化すべき要素
### 1. 端末向けBFFを1 originに集約
推奨origin例:
```text
https://jr-shikoku-backend-api-v2.haruk.in
```
BFFが以下を吸収する。
- n8n
- GAS
- static data storage
- mock/productionの環境差
- upstream timeout/retry
- last-known-good cache
- response schema normalization
- source freshness
端末は各upstream URLを知らず、BFFだけを呼ぶ。
### 2. 更新頻度別にAPIを分離
#### `GET /v2/bootstrap`
小容量の起動manifestと現在のlive summaryだけを返す。
```json
{
"schema_version": 2,
"generated_at": "2026-07-29T10:40:00Z",
"live": {
"cursor": "live-...",
"positions": [],
"operation_summary": {
"badge": "",
"affected_station_ids": [],
"text": "",
"is_information": false
}
},
"resources": {
"daily": {
"version": "2026-07-29.abc123",
"url": "/v2/resources/daily/2026-07-29.abc123.json"
},
"station_master": {
"version": "station.def456",
"url": "/v2/resources/stations/station.def456.json"
}
}
}
```
bootstrap自体へ1〜3MBを詰め込むのではなく、version確認とlive初期表示に限定する。
#### `GET /v2/live`
対象:
- positions
- operation summary
- 必要ならoperation log delta
`If-None-Match` または `?since=<cursor>` を受け、変更なしなら304/204を返す。
#### versioned daily resource
対象:
- diagram by train id
- train metadata by train id
- train-pair
- special train metadata
- アンパンマン列車の当日情報
URL自体にversionを含め、`Cache-Control: public, max-age=31536000, immutable` を付ける。更新時はbootstrapのversionだけ変える。
#### `GET /v2/train-context?train_ids=...`
対象列番だけについて以下を一括で返す。
- custom train metadata
- operations
- UnyoHub summary/entries
- えれサイト summary/entries
- position metadata
一覧画面ではviewport内の列番をbatch指定する。1列番ごとのN+1 fetchは禁止する。
### 3. 配列ではなく検索済みindexを返す
現在は端末側で以下を繰り返している。
- 1,851件のtrain-dataから `find(train_id)`
- operation logs全件から列番をsplit/map/includes
- UnyoHub/えれサイト全件から列番をfilter
- diagram配列をobjectへ変換・sort
推奨response:
```json
{
"train_meta_by_id": {
"1M": {}
},
"diagram_by_train_id": {
"1M": "..."
},
"operations_by_train_id": {
"1M": []
},
"source_summary_by_train_id": {
"1M": {
"unyohub": [],
"elesite": []
}
}
}
```
これによりアプリ側の全件scan、index作成、同一データの複数表現を削減できる。
### 4. operation flagと本文を1 responseに統合
現在はn8n flag取得後、影響駅がある場合にGAS textを追加取得する。
BFFの `operation_summary` が以下を返せば1回で済む。
- badge text
- is_information
- affected areas
- affected station IDs
- display text
- upstream updated_at
- stale状態
### 5. positionsへ位置メタデータを結合
`PosNum + Line + StationName` で別n8nへ問い合わせているplatform/line/descriptionをpositionsへ結合する。
レスポンス肥大化が気になる場合は、重複値を `position_meta_by_key` にまとめる。
### 6. permission APIをquery tokenから分離
現在はExpo push tokenを `GET /check-permission?user_id=...` に入れている。
これはURL、proxy log、edge log、Sentry自動HTTP span等へ残りやすく、cacheもしにくい。以下のいずれかへ変更する。
- `Authorization` header
- POST body
- push tokenとは別のinstallation IDを発行
permissionは公開data APIと別cache policyにする。
---
## むしろ追加すべき要素
### P0: ETag / Cache-Control / conditional GET
推奨例:
| データ | Cache-Control案 |
|---|---|
| live positions | `private, max-age=5, stale-while-revalidate=30` |
| operation summary/logs | `public, max-age=15, stale-while-revalidate=300` |
| third-party summary | `public, max-age=60, stale-while-revalidate=600` |
| versioned daily/station resource | `public, max-age=31536000, immutable` |
| bootstrap manifest | `no-cache` + ETag |
`no-cache` は「保存禁止」ではなく再検証を要求する指定として使う。
### P0: server-side last-known-good
upstream障害時に全端末がn8n→GAS fallbackを実行するのではなく、BFFが1回だけfallbackし、全端末へ最後の成功データを返す。
レスポンスに以下を含める。
```json
{
"generated_at": "...",
"source_updated_at": "...",
"stale": true,
"stale_age_seconds": 42,
"source": "last_known_good"
}
```
表示継続できるデータは200 + stale metadataで返し、保持データすらない場合だけ503にする。
### P0: upstream collector
端末リクエストのたびにGAS/n8n/JR公式へ取りに行かず、scheduled worker等がupstreamを1回取得してcacheへ保存する。
```text
upstream collector
├─ JR公式/n8n/GASを所定間隔で取得
├─ schema検証
├─ normalize/index作成
├─ last-good保存
└─ version/cursor更新
mobile BFF
└─ collector結果を低遅延で返す
```
### P1: schema versionとruntime validation
各responseへ `schema_version` を付ける。collector側で期待schemaを検証し、HTML/error pageや壊れたJSONをcacheへ昇格させない。
### P1: データ鮮度・cache hitの観測
最低限、サーバー側で以下を記録する。
- endpoint
- total duration
- upstream duration
- cache hit/miss/stale
- upstream status
- payload bytes
- item count
- source_updated_at / stale_age
- conditional requestの304率
- active client/version
追加header例:
```text
X-Data-Age: 12
X-Cache-Status: HIT
X-Source: n8n
X-Schema-Version: 2
```
### P1: change cursor / delta
positionsやoperation logは全件再送ではなく、cursor以降の変更を返せるとよい。
```json
{
"cursor": "next-cursor",
"upserts": [],
"deletes": []
}
```
最初はETag + 304だけでも効果が大きい。deltaは第2段階でよい。
### P2: invalidation通知
daily resourceや運行情報が変わったときだけ、既存のpush基盤で「version changed」を通知し、アプリが次回foreground時に再検証する方式を検討できる。
常時WebSocket/SSEはbackground、再接続、電池消費の複雑性が増すため、最初の解決策にはしない。
---
## 推奨する責務分担
| 処理 | 現在 | 推奨 |
|---|---|---|
| upstream retry/fallback | 各端末 | BFF/collector |
| stale cache | 一部memory/localStorage/AsyncStorage | BFF last-good + 端末persistent cache |
| data join | RNとWebViewの双方 | collector |
| train id index | 各component/各runtime | backend response |
| operation area→station展開 | RN hardcode | BFF operation summary |
| WebView用データ取得 | WebView自身 | RN取得結果をbridge注入 |
| environment切替 | RNの一部のみ | bootstrap origin/configで統一 |
| 更新確認 | 30秒全件GET | version/ETag/cursor |
| optional source取得 | 複数hook + WebView | train-context batch |
---
## 実装優先順位
### Phase 0: アプリだけで止められる無駄
1. `getCurrentTrain()` のmount時二重実行を1回にする。
2. WebView Phase 1/2後は30秒待ってからpollする。
3. hidden WebView preloadとroot preloadを一方だけにする。
4. 非表示positions WebViewのpollを停止する。
5. UnyoHub/えれサイトhookを共有Providerへ統合する。
6. timestamp cache busterを外す。
7. native pollへsingle-flightを入れる。
この段階だけでも起動時と定常時の重複は大幅に減る。
### Phase 1: 既存backendをcache frontにする
1. `/train-data`, `/operation-logs`, positions, GAS系にserver-side cacheを導入。
2. ETag/Cache-Control/304を追加。
3. last-known-goodとfreshness metadataを追加。
4. server-side timing/cache hitの観測を追加。
5. static resourceをversioned immutable URLへ移行。
アプリのresponse schemaを大きく変えずに導入できる。
### Phase 2: BFF v2
1. `/v2/bootstrap`
2. `/v2/live`
3. versioned daily/station resource
4. `/v2/train-context?train_ids=...`
5. operation summary統合
6. indexed response
### Phase 3: WebViewの通信所有権を廃止
React NativeがBFFから受け取ったsnapshot/deltaをWebViewへ注入する。既存のmock XHR interceptorがあるため、公式WebViewへ位置情報を渡す技術的な土台はすでに存在する。
---
## 期待効果
保守的に見ても以下を狙える。
- 主要3データの定常通信・JSON処理を、RN/WebView二重取得の解消だけで約50%削減。
- unchanged時に304を使えば、さらに本文転送・JSON parse・state更新をほぼゼロにできる。
- `train-data` とdaily diagramをversion変更時だけにすれば、30秒周期の約1.89MB展開処理を両runtimeから除去できる。
- 起動時の約6MB主要JSON処理を、live bootstrap + cache済みversion resource中心へ置き換えられる。
- GAS/n8n障害時の端末側retry stormを防止できる。
- アプリ側は複数host、fallback、area展開、全件join、複数cacheを持たずに済む。
最終的な目標は「30秒ごとに全部取り直すアプリ」から、「変更通知された小さなsnapshot/deltaを1か所で受け取るアプリ」への移行である。
---
## 主な根拠箇所
- 全Provider mount: `App.tsx`
- RN主要3データの30秒poll: `stateBox/useAllTrainDiagram.tsx`
- positionsのmount時二重取得と15秒poll: `stateBox/useCurrentTrain.tsx`
- operation flag→GAS text: `stateBox/useAreaInfo.tsx`
- WebView主要API、cache、即時poll: `lib/webViewInjectjavascript.ts`
- hidden preloadとroot preload: `Apps.tsx`
- UnyoHub/えれサイトの独立hook: `stateBox/useUnyohub.tsx`, `stateBox/useElesite.tsx`
- 位置ID補助N+1: `components/ActionSheetComponents/EachTrainInfo/TrainDataView.tsx`, `components/発車時刻表/LED_inside_Component/TrainPosition.tsx`
- アンパンマンstatus再取得: `components/ActionSheetComponents/EachTrainInfoCore/trainIconStatus.tsx`
- permission query: `stateBox/useTrainMenu.tsx`
+522
View File
@@ -0,0 +1,522 @@
# バックグラウンドりっかちゃん通知・Live Activity バックエンド連携計画(素案)
> ステータス: 相談用ドラフト
> 作成日: 2026-07-19
> 目的: n8n・Expo Push・APNs・ActivityKitを利用し、アプリが前面にいない状態でも列車追従アナウンスとDynamic Island更新を成立させる。
## 1. 結論
既存の通知システムは大部分を再利用できる。
- 通知対象の登録、購読リスト管理、列車位置の監視、送信判定はn8nを継続利用する。
- 通常の表示通知は、まず既存のExpo Push経路を利用する。
- Dynamic IslandLive Activity)のリモート更新だけは、ActivityKit専用Push Tokenを使い、n8nまたは専用APIからAPNsへ直接送信する。
- 端末で動的生成したりっかちゃん音声をExpo Push経由で再生できるかは、先に実機PoCで確認する。
- Expo経由で動的通知音が安定しない場合は、通常通知もAPNs直接送信へ切り替える。
想定する最終構成は次のとおり。
```text
アプリ
├─ ExpoPushToken ───────────────┐
├─ ActivityKit Push Token ─────┤
├─ 追従列車・運行日・有効期限 ─┤
└─ 端末に準備済みの音声一覧 ──┤
n8n / DB
列車走行位置情報API
次駅変化・到着接近を判定
┌─────────────────┴─────────────────┐
↓ ↓
Expo Push Service APNs直接送信
通常の表示・音声通知 Live Activity更新
↓ ↓
「次は、○○です。」 Dynamic Island更新
```
## 2. 実現したいユーザー体験
### 2.1 列車追従モード
1. ユーザーがアプリで列車を選び、列車追従を開始する。
2. 追従開始時に、経路上の駅について「次は、○○です。」の音声を端末内へ準備する。
3. アプリは追従条件とPush Tokenをバックエンドへ登録する。
4. アプリがバックグラウンドまたは終了状態でも、バックエンドが列車走行位置を監視する。
5. 次駅が変化したとき、表示通知とりっかちゃん音声を配信する。
6. 開始済みのLive Activityが存在する場合、Dynamic Islandも同じ状態へ更新する。
7. 終着、日付変更、ユーザー操作、有効期限切れのいずれかで追従を終了する。
### 2.2 駅固定モード
駅固定モードも同じ購読基盤へ載せられるが、最初のリリースでは列車追従モードを優先する。
将来は以下のイベントを対象にできる。
- 対象駅への列車接近
- 発車時刻または発車検知
- 一定以上の遅延発生
- 番線、行先、運休等の重要な変更
## 3. 「アプリを起動していない状態」の定義
状態によって実現方法が異なるため、仕様上は明確に区別する。
| アプリ状態 | 表示・音声Push通知 | 開始済みLive Activityの更新 | 新規Live Activity開始 |
|---|---:|---:|---:|
| フォアグラウンド | 可能 | 端末内更新・Push更新とも可能 | 可能 |
| バックグラウンド | 可能 | APNs Pushで可能 | 別途検討 |
| ユーザーがアプリを終了 | 原則可能 | Activityが存続中ならAPNs Pushで可能 | 初期版では対象外 |
| 端末再起動後 | 通知許可等に依存 | 既存Activityの状態に依存 | 初期版では対象外 |
初期版の「アプリを起動していない」は、**追従登録とLive Activity開始を一度アプリ上で行った後、アプリがバックグラウンドまたは終了状態になった場合**を指す。
バックエンドが表示通知を送る方式では、通知到着時にアプリのJavaScriptを起動する必要はない。そのため、本機能だけを理由にiOSの `UIBackgroundModes``audio``remote-notification` を追加しない。
## 4. 現在の実装と流用範囲
### 4.1 既に存在するもの
- `expo-notifications` によるExpo Push Token取得
- n8nを介した通知対象リストとExpo Push一斉送信の運用
- 列車追従・駅固定のLive Activityネイティブモジュール
- `NSSupportsLiveActivities``NSSupportsLiveActivitiesFrequentUpdates`
- Voicepeak APIによる駅名音声のWAV生成
- WAVをiOSの `Library/Sounds` へ保存するネイティブ処理
- 駅名から決定的な通知音ファイル名を作る処理
- 端末位置方式と列車走行位置方式の設定切替
### 4.2 現在の制限
- Live Activityは `pushType: nil` で開始され、ActivityKit Push Tokenを取得していない。
- 列車走行位置方式の次駅判定は、アプリのJavaScriptが動作して情報更新を受けている間しか安定して動かない。
- 動的生成した `Library/Sounds` 内のWAVを、Expo Push Serviceがカスタム通知音として確実にAPNsへ転送するか未検証。
- 通知登録に使うID、購読の有効期限、重複送信防止、Push Receipt処理の正式なデータモデルが未整備。
## 5. 配信経路の設計
### 5.1 通常通知
第一候補は既存経路を維持する。
```text
n8n → Expo Push Service → APNs → iPhone
```
送信例:
```json
{
"to": "ExponentPushToken[...]",
"title": "列車追従・りっかちゃん",
"body": "次は、坂出です。",
"sound": "rikka-next-1a2b3c.wav",
"priority": "high",
"ttl": 60,
"data": {
"schemaVersion": 1,
"type": "train-follow-announcement",
"subscriptionId": "sub_xxx",
"trainId": "123D",
"serviceDate": "2026-07-19",
"nextStation": "坂出",
"eventId": "123D:2026-07-19:next:坂出"
}
}
```
同じ駅名は全端末で同じファイル名にする。実際の音声内容は各端末がユーザーのVoicepeak設定で生成するため、サーバーは話者別の音声データを保持しなくてよい。
ただし、Pushを有効化する前に端末側で対象音声の保存完了を確認する。音声未準備の駅については、次のいずれかを仕様として選択する。
1. 通常の通知音へフォールバックする。
2. 音声なしで表示通知だけ送る。
3. 追従開始を失敗としてユーザーへ再試行を案内する。
初期案は **1の通常通知音フォールバック** とする。
### 5.2 Live Activity / Dynamic Island
ActivityKitのリモート更新はExpo Push Tokenではなく、Activity単位のPush Tokenを使用する。
```text
n8nまたはPush送信用API → APNs HTTP/2 → ActivityKit → Dynamic Island
```
必要なAPNsヘッダー:
```text
apns-push-type: liveactivity
apns-topic: <Bundle ID>.push-type.liveactivity
apns-priority: 5 または 10
```
更新例:
```json
{
"aps": {
"timestamp": 1784453400,
"event": "update",
"stale-date": 1784453520,
"content-state": {
"currentStation": "丸亀~宇多津",
"nextStation": "宇多津",
"delayMinutes": 3,
"scheduledArrival": "18:42",
"updatedAt": 1784453400
}
}
}
```
Live Activity終了時は `event: "end"` を送信し、最終表示内容とdismissal方針を定める。
### 5.3 Expo経由の動的音声が利用できない場合
通常通知もAPNs直接送信へ変更する。
```text
n8nまたはPush送信用API
├─ APNs alert push → 表示通知・Library/Sounds内の音声
└─ APNs liveactivity push → Dynamic Island
```
Appleの通知仕様では、通知音はアプリバンドルまたはアプリコンテナの `Library/Sounds` 内から指定できる。一方、Expoの公式なカスタムサウンド手順はビルド時設定を前提としているため、この分岐はPoC結果で決定する。
## 6. アプリからバックエンドへ登録する項目
### 6.1 追従購読
```json
{
"schemaVersion": 1,
"subscriptionId": "sub_xxx",
"installationId": "install_xxx",
"mode": "trainFollow",
"trainId": "123D",
"serviceDate": "2026-07-19",
"routeId": "yosan",
"destination": "高松",
"expoPushToken": "ExponentPushToken[...]",
"activity": {
"activityId": "activity_xxx",
"pushToken": "activitykit_token_hex",
"environment": "production"
},
"preparedSounds": {
"宇多津": "rikka-next-xxxx.wav",
"坂出": "rikka-next-yyyy.wav"
},
"createdAt": "2026-07-19T18:00:00Z",
"expiresAt": "2026-07-19T23:30:00Z"
}
```
### 6.2 必須項目
| 項目 | 用途 |
|---|---|
| `subscriptionId` | 購読の更新・停止・冪等性確保 |
| `installationId` | Expo TokenをユーザーIDとして扱わないための端末識別子 |
| `mode` | 列車追従・駅固定等の判別 |
| `trainId` | 監視対象列車 |
| `serviceDate` | 同じ列車番号の翌日混同防止 |
| `expoPushToken` | 通常通知の送信先 |
| `activity.pushToken` | Live Activity更新の送信先 |
| `preparedSounds` | 端末に存在する通知音名の確認 |
| `expiresAt` | 孤立した購読の自動削除 |
`activity.pushToken` は更新される可能性があるため、アプリは `pushTokenUpdates` を監視し、変化のたびにバックエンドへ再登録する。
## 7. バックエンドの状態モデル
購読とは別に、列車ごとの監視状態を保持する。
```json
{
"trainKey": "2026-07-19:123D",
"lastPosition": "丸亀~宇多津",
"currentStation": "丸亀",
"nextStation": "宇多津",
"delayMinutes": 3,
"lastSourceUpdatedAt": "2026-07-19T18:39:30Z",
"lastProcessedAt": "2026-07-19T18:39:31Z",
"lastEventId": "123D:2026-07-19:next:宇多津"
}
```
### 7.1 重複防止
通知判定は単なるポーリング回数ではなく、決定的な `eventId` で管理する。
```text
<serviceDate>:<trainId>:<eventType>:<stationId>
```
同一購読・同一 `eventId` の送信は一度だけとし、n8nの再実行やAPIの一時エラーによる重複アナウンスを防ぐ。
### 7.2 データ鮮度
列車位置情報が一定時間更新されていない場合、次駅変化を確定しない。
初期案:
- 位置情報の取得周期: 1530秒
- 情報の許容鮮度: 90秒
- 同じ変化を2回連続で観測した場合に確定。ただし情報源がイベント時刻や連番を持つ場合は再検討する。
- 終着または有効期限超過で監視終了
## 8. n8nワークフロー案
### Workflow A: 購読登録・更新
1. アプリから署名付きリクエストを受け取る。
2. スキーマ、通知許可、運行日、有効期限を検証する。
3. `subscriptionId` をキーにupsertする。
4. 同じ端末・同じ列車の古い購読を無効化する。
5. 登録結果とサーバー時刻を返す。
### Workflow B: 列車位置監視
1. 有効な購読を列車単位で集約する。
2. 同じ列車の位置情報は一度だけ取得する。
3. 前回状態と比較して、次駅・現在位置・遅延の変化を算出する。
4. データ鮮度と連続観測条件を検証する。
5. 新しい `eventId` をイベントキューへ登録する。
### Workflow C: 通常通知送信
1. イベントに該当する購読者を抽出する。
2. `preparedSounds[nextStation]` の有無を確認する。
3. 最大100件単位でExpo Pushへ送る。
4. Expo Push Ticketを保存する。
5. 後続処理でPush Receiptを取得する。
6. `DeviceNotRegistered` のExpo Tokenを無効化する。
7. 429・5xxは指数バックオフ付きで再試行する。
### Workflow D: Live Activity更新
1. ActivityKit Push Tokenを持つ購読だけ抽出する。
2. APNs JWTを生成または再利用する。
3. `liveactivity` 用ヘッダーと `content-state` を送る。
4. APNs応答を保存する。
5. 無効・期限切れTokenを無効化する。
6. 通常更新は優先度5、次駅変化等の主要イベントは必要に応じて10とする。
### Workflow E: 購読終了・清掃
以下の条件で購読を終了する。
- ユーザーが追従停止を操作
- 別列車へ追従対象を変更
- 列車が終着
- `expiresAt` 超過
- Push Tokenが無効
- 数時間にわたり列車データを取得できない
## 9. API素案
### `POST /v1/tracking-subscriptions`
追従開始。`subscriptionId` を指定した再送は冪等に処理する。
### `PATCH /v1/tracking-subscriptions/{subscriptionId}`
ActivityKit Push Token、Expo Push Token、準備済み音声一覧などを更新する。
### `DELETE /v1/tracking-subscriptions/{subscriptionId}`
追従停止。既存Live Activityをバックエンド側から終了させる必要がある場合は、削除前に `event: end` を送る。
### `POST /v1/tracking-subscriptions/{subscriptionId}/heartbeat`
初期版では必須にしない。将来、端末状態や購読継続確認が必要になった場合だけ追加する。
## 10. アプリ側の変更計画
### 10.1 ActivityKit対応
- `Activity.request(..., pushType: nil)``pushType: .token` へ変更する。
- `activity.pushTokenUpdates` を監視する。
- Activity IDとPush TokenをJSへ通知するExpo Module APIを追加する。
- Token変更、Activity終了、追従解除をバックエンドへ反映する。
- Live Activityの `ContentState` とAPNs `content-state` の型を完全一致させる。
### 10.2 追従登録
- 音声生成が完了した駅とファイル名を収集する。
- Expo Push Token、ActivityKit Push Token、列車情報を購読APIへ登録する。
- 登録中、登録済み、部分成功、失敗をUIで区別する。
- 追従停止時は購読削除を送る。通信失敗時はローカルに停止要求を保持して再試行する。
### 10.3 通知処理
- 通知タップ時に対象列車画面へ遷移する。
- `schemaVersion``type` を検証し、未知のPayloadを安全に無視する。
- フォアグラウンド受信時にも二重音声再生しない。
- 端末内通知音が欠損している場合の挙動を実機確認する。
## 11. APNs認証情報と運用
APNs直接送信には最低限、以下が必要になる。
- Apple DeveloperのAPNs Auth Key`.p8`
- Key ID
- Team ID
- Bundle ID
- development / production環境の識別
`.p8` と署名用秘密情報はアプリ、Git、n8nワークフロー定義へ直接埋め込まない。n8n Credentialsまたは専用のSecrets管理へ保存する。
n8nからAPNs HTTP/2とJWT処理を安定して扱えない場合、APNs送信だけを小さなCloudflare Worker、Lambda、Cloud Run等へ分離し、n8nはその内部APIを呼ぶ。
## 12. セキュリティ・プライバシー
- Expo Push Tokenを認証済みユーザーIDとして扱わない。
- `installationId` と購読用の署名または短期トークンを導入する。
- 登録APIをレート制限する。
- 他人の `subscriptionId` を推測して更新・削除できないようにする。
- Push Tokenをログ本文へそのまま出さず、必要なら末尾数文字のみ記録する。
- 保存する情報は追従に必要な列車、運行日、通知Tokenに限定する。
- 有効期限超過後は速やかに削除する。
## 13. 障害時の挙動
| 障害 | 推奨挙動 |
|---|---|
| 列車位置API停止 | 古い位置から通知せず、Live Activityをstale表示にする |
| Expo Push一時障害 | 指数バックオフで再試行。ただし次駅通知のTTL超過後は破棄 |
| APNs一時障害 | 短時間再試行し、次の状態更新で上書き可能にする |
| 音声未準備 | 通常音または音なしの表示通知へフォールバック |
| Activity Token無効 | Dynamic Island更新のみ停止し、通常通知は継続 |
| Expo Token無効 | 通常通知を停止し、購読を無効化または端末再登録待ちにする |
| 重複イベント | `eventId` と購読単位の送信履歴で抑止 |
| 位置情報が逆行・飛躍 | 連続観測と路線順序検証で確定を保留 |
## 14. 段階的な実装計画
### Phase 0: 技術PoC
- [ ] 実機で端末の `Library/Sounds` に動的WAVを保存する。
- [ ] n8nからExpo Pushの `sound` にそのファイル名を指定する。
- [ ] アプリが前面、背面、終了状態の3条件で音声再生を確認する。
- [ ] マナーモード、集中モード、Bluetooth、他アプリ音声再生中も確認する。
- [ ] Expo Push TicketとReceiptを保存・確認する。
- [ ] ActivityKit Push Tokenを取得する最小実装を作る。
- [ ] APNsから1回だけLive Activity更新を送る。
**判定ゲート:**
- 動的WAVがExpo経由で安定再生する → 通常通知はExpoを継続。
- 動的WAVが無音、デフォルト音、環境依存になる → 通常通知もAPNs直送へ変更。
### Phase 1: 列車追従MVP
- [ ] 列車追従購読APIを作成する。
- [ ] 列車単位の共有ポーリングと次駅変化判定を作る。
- [ ] `eventId` による重複防止を実装する。
- [ ] 「次は、○○です。」通常Push通知を実装する。
- [ ] 終着・停止・期限切れ処理を実装する。
- [ ] n8n上で送信状況を確認できるログを用意する。
### Phase 2: Dynamic Island連携
- [ ] Live Activityを `pushType: .token` で開始する。
- [ ] Token更新を購読APIへ同期する。
- [ ] APNs Live Activity更新処理を実装する。
- [ ] 次駅、現在位置、遅延、到着予定を同期する。
- [ ] stale、終了、Token無効時の処理を実装する。
### Phase 3: 駅固定モードと運用品質
- [ ] 駅固定購読を同じデータモデルへ追加する。
- [ ] 遅延・接近・発車イベントを追加する。
- [ ] 監視、メトリクス、失敗通知を整備する。
- [ ] 負荷試験とAPI障害試験を実施する。
- [ ] 不要Tokenと期限切れ購読の自動清掃を実装する。
## 15. テスト項目
### 15.1 通知音
- 対象WAVあり / なし
- アプリ前面 / 背面 / 終了
- 通常モード / マナーモード / 集中モード
- 端末スピーカー / Bluetooth / CarPlay相当環境
- 音楽や動画再生中
- 通知許可あり / サウンドのみ拒否 / 通知拒否
iOSのユーザー設定や集中モードをアプリ側から回避する設計にはしない。
### 15.2 列車位置判定
- 通常走行
- 長時間停車
- 遅延
- 位置情報欠落
- 位置の逆行または瞬間的な誤値
- 途中駅通過
- 列車番号重複と日付跨ぎ
- 併結、分割、列車番号変更がデータ上存在する場合
### 15.3 PushとLive Activity
- Token更新
- 無効Token
- 二重送信
- 順不同到着
- 古いPayload到着
- APNs 410等のエラー
- Activityがユーザーによって終了された場合
- 追従停止直後に送信イベントが競合した場合
## 16. App Review上の説明方針
本機能はバックグラウンドで継続的にオーディオ再生するものではない。列車位置判定はサーバー側で行い、利用者が明示的に登録した追従条件に対して、通常のユーザー通知として短い音声を再生する。
そのため、初期方針では `UIBackgroundModes = audio` を要求しない。Live ActivityはActivityKitとAPNsの正規手段で更新する。
Review Notesでは以下を簡潔に説明する。
- ユーザーが列車追従を明示的に開始・停止できること
- 通知と音声を設定画面で無効化できること
- 音声は通知到着時だけ短時間再生されること
- バックグラウンドで常時音声処理を行わないこと
- Dynamic IslandはActivityKit Pushで更新すること
## 17. 未決事項
別の設計レビューでは、特に以下を確認したい。
1. Expo Push経由で `Library/Sounds` の動的WAV指定が実運用上保証できるか。
2. n8nからAPNs HTTP/2へ直接送るか、署名・再試行を担当する小規模Push APIを分離するか。
3. 列車位置変化を「1回で確定」するか「2回連続観測で確定」するか。
4. 次駅アナウンスの発火点を、区間進入時、前駅発車時、次駅接近時のどこに置くか。
5. 音声準備に一部失敗した状態で追従開始を許可するか。
6. 駅固定モードをMVPへ含めるか、列車追従の安定後に追加するか。
7. APNs認証情報をn8nで保持するか、専用Push APIでのみ保持するか。
8. Live Activityの開始を常にアプリ操作必須とするか、将来Push-to-startを検討するか。
9. Voicepeak設定変更後、同じファイル名の音声をいつ再生成するか。
10. 列車走行位置情報APIの利用条件、更新間隔、障害時保証をどこまで前提にできるか。
## 18. 推奨する初期判断
- MVPは列車追従モードだけに絞る。
- n8nの既存購読リストとExpo一斉送信を維持する。
- Phase 0の実機PoCを最優先し、動的音声の配信経路を先に確定する。
- Dynamic IslandはExpo経由を試さず、最初からAPNs直接送信とする。
- APNs送信処理は将来の再利用性と秘密鍵管理を考え、可能ならn8n外の小規模APIへ分離する。
- アプリが閉じていても通知は成立させるが、初期版では追従開始そのものはアプリ操作を必須とする。
- `UIBackgroundModes = audio` は復活させない。
## 19. 参考資料
- [Apple: Generating a remote notification](https://developer.apple.com/documentation/usernotifications/generating-a-remote-notification)
- [Apple: Starting and updating Live Activities with ActivityKit push notifications](https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications)
- [Expo: Send notifications with the Expo Push Service](https://docs.expo.dev/push-notifications/sending-notifications/)
- [Expo: Notifications SDK](https://docs.expo.dev/versions/latest/sdk/notifications/)
- [既存のiOS Live Activity Push計画](./ios-live-activity-push-plan.md)
- [既存のサウンド機能計画](./sound-feature-plan.md)
+384
View File
@@ -0,0 +1,384 @@
# JR四国非公式アプリ データ取得経路マップ
作成日: 2026-07-07
## 目的
Sentryで観測している `endpoint=positions / operation_info / train_operation / timetable` が、実際にどのURL、hook、画面、バックエンド、外部取得元に対応するかを整理する。
この文書は調査結果のみ。既存コードの変更案は含めるが、実装変更は行っていない。
## 対象範囲
- React Native / Expo アプリ側の `fetch`, `observedFetchJson`, `observedFetchText`
- WebViewに注入しているJavaScript内の `fetch`
- WebView内の公式サイトXHR、およびmock XHR interceptor
- `lib/mockApi`
- `stateBox` 配下のfetch
- n8n webhook
- Google Apps Script fallback
- `jr-shikoku-backend-api-v1.haruk.in`
- `jr-shikoku-backend-mock-api-v1.haruk.in`
- `jr-shikoku-api-data-storage.haruk.in`
- JR四国公式走行位置 `https://train.jr-shikoku.co.jp/sp.html`
- JR四国公式運行情報 `https://www.jr-shikoku.co.jp/info/`
- 鉄道運用Hub / えれサイトの集約JSON
## Sentry観測対象の取得一覧
| 論理endpoint | 呼び出し元ファイル | 関数/hook | 実URL/host | path | root_tab/screen | 取得タイミング | 更新間隔 | timeout | retry | cache | fallback | Sentry tag endpoint | 備考 |
|---|---|---|---|---|---|---|---|---:|---|---|---|---|---|
| positions current mock | `lib/mockApi/positionMasters.ts` | `fetchMockTrainPositions` | `jr-shikoku-backend-mock-api-v1.haruk.in` | `/train-positions/current` | positions / startup provider | mock API有効時、`useCurrentTrain``useTrainMenu` から呼ばれる | 15秒 | 8000ms | 最大1回 | module memory last-good | 前回成功データを返す | `positions_current` / source=`mock_api` / user_visible=`true` / preload=`false` / fetch_priority=`high` | Sentryでtimeout確認済みの主対象。バックエンド側計測が必要。 |
| positions master mock | `lib/mockApi/positionMasters.ts` | `fetchPositionMasters` | `jr-shikoku-backend-mock-api-v1.haruk.in` | `/position-masters` | positions / settings-dependent | mock API有効化時、または起動時にmock有効なら取得 | 基本1回 | 8000ms | 最大1回 | module memory `_cache` | なし | `positions_master` / source=`mock_api` / user_visible=`false` / preload=`true` / fetch_priority=`medium` | PosNum+LineからPos文字列を補完。頻度は低い。 |
| positions n8n | `stateBox/useCurrentTrain.tsx` | `getCurrentTrain` | `n8n.haruk.in` | `/webhook/c501550c-7d1b-4e50-927b-4429fe18931a` | positions | 起動時、mock切替時、interval | 15秒 | 8000ms | 最大1回 | なし | Google Apps Script fallback | `positions_current` / source=`n8n` / user_visible=`true` / preload=`false` / fetch_priority=`high` | mock API無効時の主経路。成功時は録画snapshotにも使う。 |
| positions GAS fallback | `stateBox/useCurrentTrain.tsx` | `getCurrentTrain` catch内 | `script.google.com` | `/macros/s/AKfyc.../exec` | positions | n8n走行位置取得失敗時のみ | n8n失敗時のみ | 8000ms | 最大1回 | 既存データとmerge | 前回表示データ維持 | `positions_gas_fallback` / source=`gas` / user_visible=`true` / preload=`false` / fetch_priority=`high` | Direction等が欠けるため既存データとmerge。 |
| timetable today | `stateBox/useAllTrainDiagram.tsx` | `getTrainDiagram` | `jr-shikoku-api-data-storage.haruk.in` | `/tmp/diagram-today.json` または `/tmp/diagram-today-beta.json` | provider全体 / timetable consumers | provider mount直後 | 30秒、background継続指定あり。ただしobservedFetch側はbackground開始を抑制 | 15000ms | 最大1回 | AsyncStorage `ALL_TRAIN_DIAGRAM` | cache失敗時はalert | `timetable_today` / source=`static_storage` / user_visible=`false` / preload=`true` / fetch_priority=`medium` | ダイヤ・発車標・列車詳細で広く使用。 |
| train operation data | `stateBox/useAllTrainDiagram.tsx` | `getCustomTrainData` | `jr-shikoku-backend-api-v1.haruk.in` または beta | `/train-data` | provider全体 / train detail / diagram | provider mount直後 | 30秒、background継続指定あり。ただしobservedFetch側はbackground開始を抑制 | 15000ms | 最大1回 | なし | 失敗は握りつぶし | `train_operation_data` / source=`backend_api` / user_visible=`false` / preload=`true` / fetch_priority=`medium` | 約1MB級。WebView内でも別途取得される。 |
| operation logs | `stateBox/useAllTrainDiagram.tsx` | `getTodayOperation` | `jr-shikoku-backend-api-v1.haruk.in` または beta | `/operation-logs` | provider全体 / topMenu / train detail | provider mount直後 | 30秒、background継続指定あり。ただしobservedFetch側はbackground開始を抑制 | 15000ms | 最大1回 | なし | 失敗は握りつぶし | `operation_logs` / source=`backend_api` / user_visible=`false` / preload=`true` / fetch_priority=`medium` | 列車別運行ログ。WebView内でも別途取得される。 |
| operation flag | `stateBox/useAreaInfo.tsx` | `getAreaData` | `n8n.haruk.in` | `/webhook/jr-shikoku-trainfo-flag` | topMenu / information / provider全体 | mount後、InteractionManager後に1200ms遅延 | 60秒 | 10000ms | 最大1回 | なし | 失敗は握りつぶし | `operation_info_flag` / source=`n8n` / user_visible=`true` / preload=`true` / fetch_priority=`medium` | topMenuのバッジ・対象エリア判定。 |
| operation text GAS | `stateBox/useAreaInfo.tsx` | `fetchAreaDescription` | `script.google.com` | `/macros/s/AKfycbz80.../exec` | topMenu / information | operation flagで対象駅ありの場合だけ800ms遅延 | flag結果依存 | 15000ms | なし | なし | 失敗は握りつぶし | `operation_info_text` / source=`gas` / user_visible=`true` / preload=`false` / fetch_priority=`medium` | 第1回観測でtimeout。現在は通常時の実行を抑制。 |
## WebView内の取得一覧
| 論理endpoint | 呼び出し元ファイル | 関数/hook | 実URL/host | path | root_tab/screen | 取得タイミング | 更新間隔 | timeout | retry | cache | fallback | Sentry tag endpoint | 備考 |
|---|---|---|---|---|---|---|---|---:|---|---|---|---|---|
| positions official page | `components/Apps/WebView.tsx` | `AppsWebView` | `train.jr-shikoku.co.jp` | `/sp.html` | positions | 走行位置root初回activate時 | WebView lifecycle依存 | WebView標準 | WebView標準 | WebView page cache / OS依存 | remount watchdog | 未計測 | RN SentryではWebView navigation breadcrumbのみ。 |
| positions official XHR live | JR四国公式ページ内 | 公式サイトXHR | `train.jr-shikoku.co.jp` | `/g?arg1=train&arg2=train` | positions WebView | 公式ページロード後、公式サイト側ポーリング | 公式サイト実装依存。概ね数秒単位 | 公式サイト/ブラウザ依存 | 不明 | 公式サイト/ブラウザ依存 | mock interceptor可能 | 未計測 / 候補 `positions_webview` | RN wrapperを通らない。現状Sentryで通信詳細が見えない。 |
| positions official XHR static | JR四国公式ページ内 | 公式サイトXHR | `train.jr-shikoku.co.jp` | `/g?arg1=lang...`, `/g?arg1=station...`, etc | positions WebView | 公式ページロード時 | 基本初回 | 公式サイト/ブラウザ依存 | 不明 | 公式サイト/ブラウザ依存 | mock static intercept optional | 未計測 | `lib/mockApi/webviewXhrInterceptor.ts` に一覧コメントあり。 |
| positions WebView injected train-data | `lib/webViewInjectjavascript.ts` | `startPolling/DatalistUpdate` | `jr-shikoku-backend-api-v1.haruk.in` | `/train-data` | positions WebView | Phase 2後、polling開始 | 30秒 | なし | なし | WebView localStorage 1日 | 失敗は無視 | 未計測 / RN側は `train_operation` | RN側Providerと重複取得。WebView内なのでRN Sentryにspanなし。 |
| positions WebView injected operation-logs | `lib/webViewInjectjavascript.ts` | `operationListUpdate` | `jr-shikoku-backend-api-v1.haruk.in` | `/operation-logs` | positions WebView | Phase 1、Phase 3 polling、visibility復帰 | 30秒 | なし | なし | WebView localStorage 1日 | 失敗は無視 | 未計測 / RN側は `operation_info` | RN側Providerと重複取得。 |
| positions WebView injected diagram | `lib/webViewInjectjavascript.ts` | `TrainDiagramData2Update` | `jr-shikoku-api-data-storage.haruk.in` | `/tmp/diagram-today.json` | positions WebView | Phase 2、polling | 30秒 | なし | なし | WebView localStorage 1時間 | 失敗は無視 | 未計測 / RN側は `timetable` | RN側Providerと重複取得。環境切替URLが注入optionにあるが、現コード内fetch文字列は定数参照。 |
| positions WebView injected station-list | `lib/webViewInjectjavascript.ts` | Phase 1 | `n8n.haruk.in` | `/webhook/jr-shikoku-station-list` | positions WebView | WebView注入script起動時 | 初回中心 | なし | なし | WebView localStorage 1週間 | 失敗は無視 | 未計測 / 候補 `station_info` | 走行位置WebView表示の補助データ。 |
| positions WebView injected problems | `lib/webViewInjectjavascript.ts` | `getProblemsData` | `n8n.haruk.in` | `/webhook/jrshikoku-position-problems` | positions WebView | Phase 1、polling、visibility復帰 | 30秒 | なし | なし | WebView localStorage 1分 | 失敗は無視 | 未計測 / 候補 `positions_problem` | 位置情報問題データ。 |
| positions WebView unyohub | `lib/webViewInjectjavascript.ts` | `unyohubDataUpdate` | `jr-shikoku-api-data-storage.haruk.in` | `/thirdparty/unyohub-unyo.json` | positions WebView | 設定ON時、Phase 3 | 30秒 | なし | なし | WebView localStorage 1時間 | 失敗はconsole | 未計測 / 候補 `thirdparty_unyohub` | 鉄道運用Hub由来の集約JSON。 |
| positions WebView elesite | `lib/webViewInjectjavascript.ts` | `elesiteDataUpdate` | `jr-shikoku-api-data-storage.haruk.in` | `/thirdparty/elesite-unyo.json` | positions WebView | 設定ON時、Phase 3 | 30秒 | なし | なし | WebView localStorage 1時間 | 失敗はconsole | 未計測 / 候補 `thirdparty_elesite` | えれサイト由来の集約JSON。 |
| operation official page | `ndView.tsx` | operation WebView | `www.jr-shikoku.co.jp` | `/info/` | information | 運行情報root初回activate時 | WebView lifecycle依存 | WebView標準 | WebView標準 | OS/WebView依存 | remount watchdog | 未計測 | injected scriptでページ内容を加工し、画像生成結果をpostMessage。 |
| operation hidden preload | `Apps.tsx` | `HiddenStartupPreloadWebViews` | `www.jr-shikoku.co.jp` | `/info/` | startup hidden preload | 起動時preload条件成立時 | 1回 | WebView標準 | なし | OS/WebView依存 | なし | 未計測 | Sentry breadcrumb/contextのみ。 |
| positions hidden preload | `Apps.tsx` | `HiddenStartupPreloadWebViews` | `train.jr-shikoku.co.jp` | `/` | startup hidden preload | 起動時preload条件成立時 | 1回 | WebView標準 | なし | OS/WebView依存 | なし | 未計測 | 走行位置WebViewの事前ウォームアップ。 |
## Sentry endpoint対象外だが存在するRN fetch
| 用途 | 呼び出し元ファイル | 実URL/host | path | タイミング | 更新間隔 | cache | 備考 |
|---|---|---|---|---|---|---|---|
| update権限 / 外部データ利用権限 | `stateBox/useTrainMenu.tsx` | `jr-shikoku-backend-api-v1.haruk.in` または beta | `/check-permission?user_id=...` | push token取得後 | token/backend変更時 | なし | Sentry未計測。queryにuser_idあり。 |
| バス・列車データ | `stateBox/useBusAndTrainData.tsx` | `script.google.com` | GAS | mount時、AsyncStorage miss時 | 基本1回 | AsyncStorage `BUS_AND_TRAIN` | Sentry未計測。 |
| train pair data | `stateBox/useBusAndTrainData.tsx` | `script.google.com` | GAS | mount時 | 基本1回 | なし | Sentry未計測。 |
| 遅延速報EX | `stateBox/useTrainDelayData.tsx` | `script.google.com` | GAS | `loadingDelayData`変化時 | 手動/状態依存 | なし | Sentry未計測。Android widgetにも同URLあり。 |
| 特急列車情報 | `components/Menu/SpecialTrainInfoBox.tsx` | `n8n.haruk.in` | `/webhook/sptrainfo` | component mount | 1回 | なし | Sentry未計測。 |
| 位置ID補助情報 GET | `components/発車時刻表/LED_inside_Component/TrainPosition.tsx`, `components/ActionSheetComponents/EachTrainInfo/TrainDataView.tsx` | `n8n.haruk.in` | `/webhook/JR-shikoku-PosID-v3?PosId=...` | 列車詳細/LED表示時 | 表示対象変更ごと | なし | Sentry未計測。UI細部表示用。 |
| 位置ID補助情報 POST/DELETE | `TrainPositionDataPush.tsx`, `TrainPositionDataDelete.tsx` | `n8n.haruk.in` | `/webhook/JR-shikoku-PosID-v3` | ユーザー投稿/削除要求 | 操作時 | なし | Sentry未計測。mutating request。 |
| 通知設定登録 | `components/Settings/NotificationSettings.tsx` | `n8n.haruk.in` | `/webhook/jr-shikoku-notification-configurations` | 設定保存操作時 | 操作時 | AsyncStorageに設定保存 | Sentry未計測。tokenを送る。 |
| unyohub RN hook | `stateBox/useUnyohub.tsx` | `jr-shikoku-api-data-storage.haruk.in` | `/thirdparty/unyohub-unyo.json` | 設定ON時 | 10分 | AsyncStorage | WebView内でも別取得あり。 |
| elesite RN hook | `stateBox/useElesite.tsx` | `jr-shikoku-api-data-storage.haruk.in` | `/thirdparty/elesite-unyo.json` | 設定ON時 | 10分 | AsyncStorage | WebView内でも別取得あり。 |
| 駅住所LOD | `components/駅名表/AddressText.tsx` | station別 `jslodApi` | `.json` | 駅名標表示時 | 表示対象変更ごと | なし | 外部LOD。Sentry未計測。 |
| Anpanman列車状態 | `components/ActionSheetComponents/EachTrainInfoCore/trainIconStatus.tsx` | `n8n.haruk.in` | `/webhook/{anpanmanApiPath}?trainNum=...` | 対象列車詳細表示時 | 対象変更ごと | なし | Sentry未計測。 |
| Android operation widget | `components/AndroidWidget/InfoWidget.tsx` | `script.google.com` | operation info GAS | widget更新時 | widget runtime依存 | なし | RN画面外。 |
| Android delay widget | `components/AndroidWidget/TraInfoEXWidget.tsx` | `script.google.com` | delay GAS | widget更新時 | widget runtime依存 | なし | RN画面外。 |
| GeneralWebView recording import | `GeneralWebView.tsx` | arbitrary / blob / resolvedUri内 | download URL | 録画importリンク操作時 | 操作時 | なし | 任意URL由来。Sentry endpoint化すべきではない。 |
## endpoint別依存関係
### positions
```text
positions screen
├─ RN provider: useCurrentTrain
│ ├─ mock ON: jr-shikoku-backend-mock-api-v1 /train-positions/current
│ │ └─ fallback: module memory last-good cache
│ └─ mock OFF: n8n /webhook/c501550c...
│ └─ fallback: Google Apps Script positions fallback
│ └─ fallback: currentTrain previous state if present
├─ RN provider: useTrainMenu
│ ├─ mock ON: /position-masters
│ └─ mock ON: /train-positions/current, then inject to WebView mock XHR
└─ WebView: train.jr-shikoku.co.jp/sp.html
├─ official XHR /g?arg1=train&arg2=train
├─ official XHR static /g?... station/lang/etc
└─ injected enhancement fetches backend/storage/n8n auxiliary data
```
表示対象:
- 走行位置画面
- 列車詳細ActionSheet
- 発車標/LED系
- fixed position box
現Sentry tag:
- `endpoint=positions`
- WebView公式XHRは未計測。候補は `positions_webview`
### operation_info
```text
topMenu / information / train detail
├─ useAreaInfo
│ ├─ n8n /webhook/jr-shikoku-trainfo-flag
│ └─ if active area exists: Google Apps Script operation text
├─ useAllTrainDiagram
│ └─ jr-shikoku-backend-api-v1 /operation-logs
├─ information WebView
│ └─ www.jr-shikoku.co.jp/info/ official page
│ └─ injected script parses/captures page and postMessage to RN
└─ widgets / delay providers
├─ operation info GAS
└─ delay GAS
```
表示対象:
- topMenuバッジ・運行情報カード
- informationタブ
- 列車詳細内の運行ログ表示
- Android widgets
現Sentry tag:
- `endpoint=operation_info`
- WebView公式ページは未計測。
### train_operation
```text
train detail / diagram / WebView enhancement
├─ RN: useAllTrainDiagram -> backend /train-data
└─ WebView injected JS -> backend /train-data
└─ localStorage cache 1 day
```
表示対象:
- 列車詳細
- ダイヤ/発車標
- 走行位置WebViewの列車アイコン・編成/運用装飾
現Sentry tag:
- RN fetch: `endpoint=train_operation`
- WebView fetch: 未計測
### timetable
```text
timetable / station diagram / train detail
├─ RN: useAllTrainDiagram -> data-storage /tmp/diagram-today.json
│ └─ fallback: AsyncStorage ALL_TRAIN_DIAGRAM
└─ WebView injected JS -> data-storage /tmp/diagram-today.json
└─ localStorage cache 1 hour
```
表示対象:
- 時刻表
- 発車標
- 列車詳細停車駅/通過駅
- 走行位置WebView内の補助表示
現Sentry tag:
- RN fetch: `endpoint=timetable`
- WebView fetch: 未計測
## 画面別fetch一覧
### アプリ起動直後
Providerがmountされるため、現在の構成では表示タブに関係なく以下が走り得る。
| 取得 | 起動時挙動 | 備考 |
|---|---|---|
| `useCurrentTrain.getCurrentTrain` | 即時1回、その後15秒interval | positions。mock ON/OFFで経路が変わる。 |
| `useAllTrainDiagram.getTrainDiagram` | 即時1回、その後30秒interval | timetable。AsyncStorage fallbackあり。 |
| `useAllTrainDiagram.getCustomTrainData` | 即時1回、その後30秒interval | train_operation。 |
| `useAllTrainDiagram.getTodayOperation` | 即時1回、その後30秒interval | operation_info。 |
| `useAreaInfo.getAreaData` | InteractionManager後 + 1200ms遅延。その後60秒interval | operation_info flag。対象ありならGAS textも800ms遅延。 |
| `useTrainMenu.check-permission` | push token取得後 | Sentry未計測。 |
| hidden preload WebViews | preload条件成立時 | positions公式/operation公式を不可視WebViewでロード。 |
### topMenu表示中
| 取得 | 発生源 | 備考 |
|---|---|---|
| positions RN polling | `useCurrentTrain` provider | 表示タブに関係なく継続。ただしbackgroundではinterval停止。 |
| timetable / train_operation / operation_logs | `useAllTrainDiagram` provider | 表示タブに関係なく30秒ごと。 |
| operation flag / text | `useAreaInfo` | topMenu表示内容に直結。 |
| special train info | `SpecialTrainInfoBox` | 該当Sheet表示時のみ。 |
### positions表示中
| 取得 | 発生源 | 備考 |
|---|---|---|
| positions RN polling | `useCurrentTrain` | 15秒。画面表示に必須。 |
| train.jr-shikoku公式WebView | `AppsWebView` | 公式XHRはRN Sentry不可視。 |
| WebView injected train-data / operation-logs / diagram / station-list / problems | `webViewInjectjavascript` | RN providerと重複取得あり。WebView localStorage cacheあり。 |
| unyohub / elesite | WebView injected JS、またはRN hooks | 設定ON時のみ。 |
### information表示中
| 取得 | 発生源 | 備考 |
|---|---|---|
| JR四国公式運行情報WebView | `ndView.tsx` | `https://www.jr-shikoku.co.jp/info/`。RN Sentryではpage load breadcrumb中心。 |
| operation flag/text | `useAreaInfo` | バッジ/本文補助。 |
| operation logs | `useAllTrainDiagram` | 列車別運行ログ。 |
### バックグラウンド復帰時
| 取得 | 発生源 | 備考 |
|---|---|---|
| `useInterval` | AppState active復帰時に即時実行 | userStoppedでなければ即時fetch。observedFetch側はbackground開始を抑制。 |
| positions WebView | `useWebViewRemount` / WebView lifecycle | iOSはunfocused時watchdog pause設定あり。 |
| WebView injected `refreshAllData` | document visibilitychange/pageshow | WebView内で train-data / operation-logs / position-problems を再取得。RN Sentry不可視。 |
| GeneralWebView | background 10秒超でremount | 汎用WebViewのみ。 |
## 起動時/タブ切替時fetchタイムライン
```text
T+0ms app providers mount
├─ useCurrentTrain -> positions current fetch
├─ useAllTrainDiagram -> timetable fetch
├─ useAllTrainDiagram -> train_operation fetch
├─ useAllTrainDiagram -> operation_logs fetch
└─ useTrainMenu -> settings/env load, maybe check-permission after token
T+after interactions + 1200ms
└─ useAreaInfo -> operation_info_flag
└─ if active area exists: +800ms -> operation_info_text GAS
TopMenu first screen
└─ no page-specific heavy WebView, but provider fetches above continue
positions first activation
├─ AppsWebView loads train.jr-shikoku.co.jp/sp.html
├─ official site starts /g?... XHRs
├─ injected JS Phase 0: WebView localStorage cache read
├─ injected JS Phase 1: station-list, operation-logs, position-problems
├─ injected JS Phase 2: train-data, diagram-today
└─ injected JS Phase 3: 30s polling, optional unyohub/elesite
information first activation
├─ ndView WebView loads www.jr-shikoku.co.jp/info/
└─ injected script parses/captures page and postMessage to RN when user capture actions occur
Tab switch back to positions/information
├─ root component is kept after activation
├─ WebView retention depends on current navigation/root gating and OS WebView process
└─ provider-level RN fetches generally continue independent of active tab
```
## 危険度・制御方針
### High priority
| 取得 | 分類理由 | 推奨方針 |
|---|---|---|
| `/train-positions/current` | 表示中timeoutがユーザー体感に直結。Sentryで実測済み。 | server-side duration/upstream/cacheログ、backend last-known-good、app persistent stale cache。 |
| `useCurrentTrain` n8n positions | mock OFF時の主経路。走行位置画面に必須。 | stale cache導入、失敗時UI全体error回避、endpoint細分化。 |
| JR公式WebView `/g?arg1=train&arg2=train` | 走行位置WebViewの本体だがRN Sentry不可視。 | WebView postMessage計測、またはRN側 proxy/cache化を検討。 |
| `/operation-logs` | WebView/RNで重複、列車詳細表示に影響。 | backend span/cache hit/miss、WebView側計測。 |
| `/train-data` | 大きいレスポンス、RN/WebViewで重複。 | stale cache、conditional fetch、WebView/RN共有設計。 |
### Medium priority
| 取得 | 分類理由 | 推奨方針 |
|---|---|---|
| `diagram-today.json` | 広範囲で使うがAsyncStorage fallbackあり。 | stale cache明示、WebView側との重複削減。 |
| `operation_info_flag` | topMenuバッジ/運行情報表示に影響。 | endpoint細分化、失敗時は前回状態維持。 |
| `operation_info_text` GAS | 遅延/timeout履歴あり。必須ではない詳細本文。 | preloadではなく遅延維持、stale cache、cellular抑制候補。 |
| station-list / position-problems WebView | 表示補助。WebView内でのみ見えにくい。 | WebView breadcrumb/postMessage計測。 |
| unyohub / elesite | 設定ON時のみだがWebView内は30秒polling。 | cellular抑制、10分程度へ間引き、Sentry breadcrumb。 |
### Low priority
| 取得 | 分類理由 | 推奨方針 |
|---|---|---|
| bus/train GAS | 初回またはcache miss中心。 | observedFetch化は後回し。 |
| train pair GAS | 補助データ。 | cache導入候補。 |
| special train info | Sheet表示時のみ。 | 失敗UIだけ整備。 |
| notification config | ユーザー操作時のみ。 | mutationとして個別エラーハンドリング。 |
| station LOD address | 駅名標詳細のみ。 | 失敗しても無表示でよい。 |
| Anpanman status | 装飾表示。 | 失敗してもUI全体に影響させない。 |
## stale cacheを入れるべき取得
| 優先度 | 取得 | 理由 | 推奨cache |
|---|---|---|---|
| High | `/train-positions/current` | 走行位置表示が直接壊れる | backend last-known-good + app persistent cache。TTL 30-120秒。 |
| High | n8n positions | mock OFF時の主経路 | app persistent last-good。TTL 30-120秒。 |
| High | JR公式WebView train XHR | WebView表示本体 | 公式XHRをRN/proxy化できるならstale。難しければWebView localStorage/postMessage。 |
| Medium | `/operation-logs` | 列車詳細/運行情報補助 | backend/app cache。TTL 1-5分。 |
| Medium | `/train-data` | 大容量かつ重複取得 | app/WebView共有cache。TTL 1日でも許容しやすい。 |
| Medium | `diagram-today.json` | 既にAsyncStorage fallbackあり | 既存cacheをstale表示として明示。TTL 1日。 |
| Medium | `operation_info_flag` | バッジ/エリア判定 | 前回状態維持。TTL 1-5分。 |
| Medium | `operation_info_text` | GAS遅延に弱い | 前回本文保持。TTL 5-30分。 |
| Low | unyohub / elesite | 設定ON時の補助 | 既存AsyncStorage/WebView localStorageを活用。TTL 10-60分。 |
## バックエンド側に計測を入れるべきAPI
| API | host | 理由 | 必要な計測 |
|---|---|---|---|
| `/train-positions/current` | `jr-shikoku-backend-mock-api-v1.haruk.in` | Sentryでforeground timeout確認済み。 | handler duration, upstream duration, cache hit/miss, stale served, status, bytes, upstream error kind。 |
| `/position-masters` | `jr-shikoku-backend-mock-api-v1.haruk.in` | mock経路の基礎データ。頻度低いが失敗時mock表示品質に影響。 | handler duration, cache hit/miss, db/storage duration。 |
| `/train-data` | `jr-shikoku-backend-api-v1.haruk.in` | 大容量・RN/WebView重複取得。 | handler duration, data source duration, response bytes, compression, cache status。 |
| `/operation-logs` | `jr-shikoku-backend-api-v1.haruk.in` | operation_infoとしてRN/WebView重複取得。 | handler duration, query/upstream duration, cache status, filtered count。 |
| `/check-permission` | `jr-shikoku-backend-api-v1.haruk.in` | user_id queryあり、失敗しても表示は続くが権限UIに影響。 | duration, auth/lookup duration, no PII logging。 |
| `diagram-today.json` publish pipeline | `jr-shikoku-api-data-storage.haruk.in` | 静的配信だが生成元障害は広範囲影響。 | CDN/storage access log, generation timestamp, object age, size。 |
| `thirdparty/unyohub-unyo.json` | `jr-shikoku-api-data-storage.haruk.in` | 外部由来の集約結果。 | generation timestamp, upstream scrape duration, source error count。 |
| `thirdparty/elesite-unyo.json` | `jr-shikoku-api-data-storage.haruk.in` | 外部由来の集約結果。 | generation timestamp, upstream scrape duration, source error count。 |
## Sentry endpoint tag改善案
RN側observedFetchについては、4分類から以下の低カーディナリティな論理endpointへ細分化済み。`area=data_fetch`, `result`, `root_tab`, `status` は維持し、`source`, `user_visible`, `preload`, `fetch_priority` を追加した。
### 現在のRN側endpoint分類
低カーディナリティを維持するため、URL単位ではなく論理単位で増やす。
| 旧endpoint | 現endpoint | 対象 |
|---|---|---|
| `positions` | `positions_current` | 現在位置本体。n8n current / mock currentを含めるかは要検討。 |
| `positions` | `positions_master` | `/position-masters`。 |
| `positions` | `positions_gas_fallback` | Google Apps Script fallback。 |
| 未計測 | `positions_webview` | JR公式WebView XHR / postMessage計測。 |
| `operation_info` | `operation_info_flag` | n8n flag。 |
| `operation_info` | `operation_info_text` | Google Apps Script text。 |
| `operation_info` | `operation_logs` | backend `/operation-logs`。 |
| `train_operation` | `train_operation_data` | backend `/train-data`。 |
| `timetable` | `timetable_today` | `diagram-today.json`。 |
| 未計測 | `thirdparty_unyohub` | unyohub集約JSON。 |
| 未計測 | `thirdparty_elesite` | elesite集約JSON。 |
### 追加tag
- `source=rn_fetch|mock_api|n8n|gas|backend_api|static_storage|webview_fetch`
- `user_visible=true|false`
- `preload=true|false`
- `fetch_priority=high|medium|low`
### 注意
- `endpoint` tagにURL全文やID入りpathは入れない。
- `urlHost``urlPathTemplate` はcontextに維持する。
- `positions_current` をn8n/mock/GASで分けすぎるとissue数は追いやすいが、全体のpositions失敗率は見にくくなる。`source` tagまたはcontextで `mock_api / n8n / gas` を分ける案も有効。
## 変更案の優先順位
### High
1. `positions_current` のendpoint細分化、または `source=mock_api|n8n|gas` 追加。
2. WebView公式XHR `/g?arg1=train&arg2=train` のpostMessage計測。
3. backend mock API `/train-positions/current` にserver-side span/cache/upstream durationを追加。
4. positionsのpersistent stale cache。
### Medium
1. `operation_info``operation_info_flag / operation_info_text / operation_logs` に分離。
2. WebView injected JS fetchの軽量postMessage計測。
3. `/train-data``diagram-today.json` のRN/WebView重複取得を整理。
4. unyohub/elesiteのWebView polling間隔を30秒から設定/TTLベースへ見直し。
### Low
1. LED/駅詳細/通知/Widget系の直接fetchをobservedFetch化。
2. 任意URLを扱うGeneralWebView importはSentry endpoint化せず、操作エラーとして別分類。
3. station LOD address fetchの失敗をUI非表示に統一。
## 現時点の結論
- Sentryで見えている `positions` のtimeoutは、RN側のmock currentまたはn8n/GAS経路のどれかに紐づく。今回観測された `urlHost=jr-shikoku-backend-mock-api-v1.haruk.in`, `urlPathTemplate=/train-positions/current` はmock currentで確定。
- 走行位置画面ではRN provider fetchとWebView公式XHR、さらにWebView injected fetchが並行して走る。見えているSentry spanはRN provider側だけで、WebView内の公式取得は不可視。
- `operation_info` はflag、GAS text、backend operation logs、公式WebViewの4系統が混在しているため、細分化した方が原因追跡しやすい。
- `train_operation``timetable` はRNとWebViewで重複取得している。ユーザー体感の安定化には、stale cacheと重複削減が効きやすい。
- バックエンド側で最優先に計測すべきは `/train-positions/current`。次点で `/train-data``/operation-logs`
@@ -0,0 +1,277 @@
# 通信計測ロガー実装報告 2026-07-07
## 目的
走行位置・運行情報・時刻表まわりの RN 側データ取得について、Sentry 上で endpoint 別の成功/失敗、HTTP status、通信時間、レスポンスサイズ、JSON 期待時の HTML 返却、JSON parse 失敗、timeout を追えるようにした。
今回の実装は低リスクな第1段階として、WebView 内 fetch と stale cache の実装は対象外にした。既存のバックグラウンド WebView 保持・タブ保持挙動には触れていない。
## 実装内容
### 追加した共通実装
- `lib/observability/network/types.ts`
- `lib/observability/network/endpoints.ts`
- `lib/observability/network/networkError.ts`
- `lib/observability/network/sentryNetwork.ts`
- `lib/observability/network/observedFetch.ts`
主な API:
```ts
observedFetchJson<T>(url, options)
observedFetchText(url, options)
```
記録する分類:
- `success`
- `slow_success`
- `timeout`
- `network_error`
- `http_error`
- `non_json_response` / Sentry tag result は `non_json`
- `json_parse_error` / Sentry tag result は `parse_error`
- `empty_response`
- `unknown`
### Sentry 設定
`App.tsx``Sentry.init` に以下を追加した。
- `tracesSampleRate: __DEV__ ? 1.0 : 0.05`
- `beforeSend``contexts.data_fetch.responseHead` を最大300文字に制限
既存の Replay / Feedback / Logs 設定は維持した。
### 計測対象にした RN fetch
#### 走行位置
- `stateBox/useCurrentTrain.tsx`
- n8n の走行位置 webhook
- Google Apps Script fallback
- `lib/mockApi/positionMasters.ts`
- mock position masters
- mock train positions current
endpoint tag: `positions`
#### 運行情報
- `stateBox/useAllTrainDiagram.tsx`
- `/operation-logs`
- `stateBox/useAreaInfo.tsx`
- 運行情報本文 text
- `jr-shikoku-trainfo-flag`
endpoint tag: `operation_info`
#### 運用・時刻表系
- `stateBox/useAllTrainDiagram.tsx`
- `/train-data`
- `diagram-today.json`
endpoint tag:
- `/train-data`: `train_operation`
- `diagram-today.json`: `timetable`
## Sentry に入る情報
### tags
- `area=data_fetch`
- `endpoint=positions|operation_info|train_operation|timetable`
- `result=success|slow_success|timeout|network_error|http_error|non_json|parse_error|empty_response|unknown`
- `root_tab=positions|topMenu|information|unknown`
- `platform=android|ios`
- `source=rn_fetch`
- `status=200` など、HTTP response がある場合のみ
`root_tab` は呼び出し側で明示指定しない場合、`rootNavigationRef` の active root route から補完する。
### contexts.data_fetch
- `endpoint`
- `method`
- `status`
- `ok`
- `durationMs`
- `timeoutMs`
- `bytes`
- `contentType`
- `responseHead` 失敗時中心、最大300文字
- `retryCount`
- `urlHost`
- `urlPathTemplate`
- `appState`
URL 全文や query parameter は送らず、host と path template のみを送る方針にした。
## 挙動仕様
- timeout は wrapper 内の `AbortController` で実装。
- `retry: true` の GET 系取得のみ、最大1回 retry。
- retry 対象は `timeout`, `network_error`, HTTP `502/503/504`
- `non_json_response`, `json_parse_error`, `empty_response`, HTTP `4xx` は retry しない。
- 成功は Issue 化しない。breadcrumb と span にのみ記録。
- `slow_success` の captureMessage は第1段階では無効化した。breadcrumb/span には残る。
- JSON endpoint は HTTP 200 だけでは success とせず、JSON parse 成功後に success 記録する。
## 未実装・次段階
- WebView 内 fetch の計測 hook は未実装。
- stale cache fallback の共通化は未実装。
- connection type の取得は未実装。必要なら `@react-native-community/netinfo` 等の導入可否を確認する。
- Sentry metrics は未実装。今回は span / breadcrumb / warning/error event に限定。
- slow success の warning event 化は無効。Sentry ノイズを見てから sample rate を上げる。
## 実機テスト観点
### 通常系
1. アプリを起動する。
2. トップメニューを表示する。
3. 走行位置タブを開く。
4. 運行情報タブを開く。
5. しばらく放置して 30 秒 interval の再取得を待つ。
期待:
- UI 挙動が従来と変わらない。
- Sentry の event / span / breadcrumb に `area:data_fetch` が見える。
- `endpoint:positions`, `endpoint:operation_info`, `endpoint:train_operation`, `endpoint:timetable` で絞れる。
### 失敗系
1. 機内モード、または通信不安定状態で起動する。
2. 走行位置・運行情報を開く。
期待:
- `result:timeout` または `result:network_error` が Sentry に warning として出る。
- `contexts.data_fetch.durationMs`, `timeoutMs`, `endpoint`, `root_tab` が入る。
- 既存 UI の fallback / error 表示が大きく変わらない。
### non_json / parse_error
実機で一時的に endpoint を HTML 返却 URL に向ける場合のみ確認。通常の配信ビルドでは無理に発生させない。
期待:
- HTML 返却は `result:non_json` / `kind:non_json_response`
- JSON parse 失敗は `result:parse_error` / `kind:json_parse_error`
- `responseHead` は最大300文字。
## Sentry 確認クエリ
```text
area:data_fetch
area:data_fetch endpoint:positions
area:data_fetch endpoint:operation_info
area:data_fetch result:timeout
area:data_fetch result:network_error
area:data_fetch result:non_json
area:data_fetch result:parse_error
area:data_fetch platform:android
area:data_fetch platform:ios
area:data_fetch source:rn_fetch
```
Performance / spans 側では `op:http.client`、span name `fetch positions` などで確認する。
## 検証状況
- `git diff --check` は対象ファイルで通過。
- `npx tsc --noEmit` は失敗するが、今回追加した network observability 由来の TypeScript エラーは出ていない。
- 残っている主な既存エラー:
- `Apps.tsx``event.preventDefault`
- `expo-live-activity` 型解決
- `components/trainMenu.web.tsx` の web 側型エラー
- `lib/felicaStationMap.ts` の重複 object key
## 2026-07-07 第1回観測後の追加修正
第1回観測で `operation_info``script.google.com` が timeout したため、以下を追加修正した。
- timeout 時の Sentry primary error が `AbortError` に寄らないよう、timeout では `ObservedFetchError("Network request timed out")` を primary として `captureException` する。timeout 時は元の AbortError を `cause` に入れない。
- `root_tab``unknown` になりにくいよう、`rootNavigationRef` から現在 route を取れない場合は `lastObservedRootRouteRef` を fallback にする。
- `Apps.tsx` の root navigation state 更新時に、最後に観測した root route を `lastObservedRootRouteRef` に保存する。
- `operation_info` の Google Apps Script text 取得は通常時に必ず走らせず、n8n の flag 取得で対象エリアがある場合だけ遅延実行する。
- 起動直後や `topMenu` 表示中の負荷を避けるため、初回 `operation_info` flag 取得は `InteractionManager.runAfterInteractions``setTimeout` で遅延する。
- 自作 span は `name` / `description` ともに `fetch <endpoint>` に統一した。実 URL が出る自動 HTTP span は Sentry SDK 側の自動計測として許容する。
この変更により、通常時の `script.google.com` text 取得は発生しにくくなり、運行情報対象駅がある場合のみ補助テキストとして取得される。
## 注意
今回の変更は観測基盤の第1段階であり、通信失敗時の UI 改善や stale cache 表示は含まない。実機で `area:data_fetch` のイベント量とノイズを確認してから、WebView fetch 計測と stale fallback を次段階で検討する。
## 第2回観測対応: positions timeout / background noise / huge span
Sentry観測で、foreground / positions表示中 / online / wifi の状態で `endpoint=positions` が timeout していた。
対象は `jr-shikoku-backend-mock-api-v1.haruk.in/train-positions/current`
観測値は `durationMs ~= 8028ms`, `timeoutMs = 8000`, `retryCount = 1` で、これはユーザー体感に直結する取得失敗として扱う。
### 実装したアプリ側対策
- `observedFetch` のtimeoutを `AbortController` 依存だけでなく `Promise.race` に変更した。
- React Native側のfetch abortが即時rejectしない場合でも、自作Sentry spanはtimeout時点で終了する。
- `operation_info` の29分級spanは、この経路が主因候補。
- retry前にruntime状態を再確認するようにした。
- `AppState.currentState === "background"` ならretryしない。
- `navigator.onLine === false` が取れる環境ではoffline時もretryしない。
- background/offline由来の取得抑制は `aborted` breadcrumb に留め、Issue化しない。
- fetch失敗後にruntimeがbackground/offlineなら `captureException` しない。
- `positions` のmock API取得にアプリ内last-good cacheを追加した。
- `/train-positions/current` がtimeout/network errorになっても、前回成功データがあればそれを返す。
- stale cache利用時は `fetch:stale_cache` breadcrumb を残す。
- 走行位置画面側でも、前回表示データがある場合は取得失敗で `currentTrainLoading=error` に落とさず `success` を維持する。
- `operation_info` の遅延fetch timerをcleanupするようにした。
- `InteractionManager.runAfterInteractions` 後の `setTimeout` がProvider破棄後に残る経路を潰した。
- Google Apps Script詳細取得の遅延timerも多重起動時に前回timerをclearする。
### このリポジトリで実装できなかったバックエンド側項目
このリポジトリ内を検索したが、`/train-positions/current` のサーバー実装は含まれていない。
見つかったのはフロント側の呼び出し元 `lib/mockApi/positionMasters.ts` のみ。
そのため、以下はバックエンド側リポジトリで実装が必要。
- `/train-positions/current` handler全体のserver-side duration log
- Sentry transaction/span
- upstream JR四国取得duration
- cache hit/miss
- stale/last-known-good cache返却
- upstream timeout時に200 + stale payloadを返すか、少なくとも短時間で明示的なfallback responseを返す設計
### 外部APIの簡易計測
2026-07-07時点で手元から直接 `curl` 計測した結果:
```
http_code=200
time_total=1.449047
time_connect=0.358893
time_starttransfer=1.448716
size_download=4887
```
この1回の計測では8秒遅延は再現していない。
Sentry上のtimeoutは一過性の上流遅延、バックエンド側のcache miss、または該当時刻の外部取得詰まりを疑う。
### 次にSentryで見るポイント
- `area:data_fetch endpoint:positions result:timeout` がIssue化されるのは foreground / online のみになっているか。
- timeout後に `fetch:stale_cache` breadcrumb が付くか。
- `operation_info` のspan durationが15秒timeout近辺で止まり、29分級spanが再発しないか。
- background中の `network_error` がIssue化されずbreadcrumbに留まるか。
### 残課題
- offline判定は現時点では `navigator.onLine` が取れる環境に限定される。
React Nativeで確実に判定するなら `@react-native-community/netinfo` などの導入が必要。
- last-good cacheはアプリプロセス内メモリのみ。アプリ再起動後もstale表示したい場合は永続cacheが必要。
- 根本対策はバックエンド側で `/train-positions/current` のlast-known-good cacheを返すこと。
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
# 保存した運用録画のファイルインポート/エクスポート方針
## 目的
保存した走行位置録画を、クリップボードだけでなくファイルとして受け渡しできるようにする。
録画データを JSON として書き出し、同じ JSON を選択して取り込める構成にする。
## 導入済みの必須ライブラリ
- `expo-file-system`: 録画 JSON を一時ファイルまたは Documents 配下へ書き出し、インポート時に選択済みファイルを読み込む。
- `expo-document-picker`: ユーザーに録画 JSON ファイルを選ばせる。読み込み直後に扱いやすくするため、実装時は `copyToCacheDirectory: true` を使う。
既存の `expo-sharing` は単一ファイルの共有 fallback として残す。`react-native-share` は複数画像や複数ファイルの同時共有、共有先ごとの制御が必要なケースで使う。既存の `expo-clipboard` はコピペ export/import の fallback として残す。
## 実装済み
- 録画1件と全件の JSON envelope 書き出し。
- `react-native-share` によるネイティブ共有/保存。
- Web では `Blob``download` による JSON ダウンロード。
- `expo-document-picker``expo-file-system` による JSON 読み込み。
- 既存保存形式と同じ検証処理を通したインポート。
- 録画本体は `Paths.document/train-recordings` 配下の JSON ファイルへ保存し、AsyncStorage/SQLite には軽量な一覧メタだけ保存する。
- 旧SQLite保存の録画本体は、起動時/インポート時にファイルへ移行して旧キーを削除する。
## 想定する実装方針
### ネイティブ
1. 録画データを version 付き envelope JSON に変換する。
2. `expo-file-system``cacheDirectory``.json` ファイルを書き出す。
3. 単一 JSON は `expo-sharing` または `react-native-share` で共有する。
4. 複数画像や複数ファイルを同時共有する場合は `react-native-share``urls` を使う。
5. インポートは `expo-document-picker` で JSON を選択する。
6. 選択したファイルを `expo-file-system` で読み込み、既存の検証処理を通して保存する。
### Web
Web ではローカル file URI の `expo-sharing` が使えないため、`Blob``URL.createObjectURL` による download を別実装にする。
インポートはブラウザの file input 相当で読み込む方針にする。
## 正式採用する共有ライブラリ
- `react-native-share`: 複数画像や複数ファイルを同時共有するために正式採用する。Expo config plugin は options 付きで登録し、初期状態では `ios``android` の query 追加を空配列にしている。特定アプリ向けの `shareSingle``isPackageInstalled` が必要になった時点で、対象アプリの scheme/package を追加する。
- `enableBase64ShareAndroid`: 初期状態では `false`。ファイル URI 共有を基本にするため、Android の legacy storage permission を増やさない。base64 共有が必要になった場合だけ有効化する。
## 今回インストールしない候補
- `pako` または `fflate`: 録画 JSON が大きくなったときの gzip/deflate 圧縮候補。まずは plain JSON で運用し、共有できないサイズになった時点で入れる。
- `expo-file-system` config plugin options: iOS の Files アプリからアプリ Documents を直接見せたい場合に `enableFileSharing``supportsOpeningDocumentsInPlace` を検討する。現時点では共有シートと DocumentPicker で足りる。
- `expo-sharing` の共有受け取り設定: 他アプリから「このアプリで開く」導線を作る候補。まずはアプリ内のインポートボタンから選択する方式にする。
- `react-native-blob-util`: 大容量/バックグラウンド/Android Storage Access Framework の細かい制御が必要になった場合の候補。Expo managed workflow では導入コストが上がるため、現時点では使わない。
## 参考
- https://docs.expo.dev/versions/latest/sdk/filesystem/
- https://docs.expo.dev/versions/latest/sdk/document-picker/
- https://docs.expo.dev/versions/latest/sdk/sharing/
@@ -0,0 +1,372 @@
# Sentry Crash 調査履歴
## 目的
このメモは、現在追っているクラッシュ調査の経緯を ChatGPT 側で再考察しやすい形に整理したもの。
## 現象
- `top menu` を表示した後、タブ切り替えや回転をきっかけにクラッシュする。
- Android / iOS の両方で再現した。
- 既存の `lazy` / `detachInactiveScreens` は必須前提として扱う。
- 走行位置、運行情報、トップメニューの各画面で WebView / ネイティブ View のマウントが絡んでいる。
## 最初の主要シグナル
- Sentry の主エラーは `RetryableMountingLayerException: Unable to find viewState for tag ...`
- 典型的には Fabric / New Architecture のネイティブ UI ツリー更新失敗を示していた。
- つまり JavaScript の例外というより、View の mount / unmount タイミング競合を疑うべき状態だった。
## 試した方向性の履歴
### 1. 遷移タイミングをずらす
- `navigate` / `replace` / `reset` の直後に落ちるケースを疑い、遷移を一拍遅らせる案を検討した。
- ただし症状は完全には止まらず、遷移先だけの問題ではなかった。
### 2. ライフサイクル依存の自動解放を抑える
- iOS でブラウザや WebView がタブ切り替え時に開放される問題があり、`lazyload` を外す方向は破綻した。
- そのため、WebView を「常時破棄しない」方向を維持する必要があった。
### 3. Sentry の観測点を増やす
- 画面ごとに breadcrumb と context を追加した。
- 追加した主な観測点:
- `Apps.tsx`
- `Top.tsx`
- `components/Apps.tsx`
- `components/Apps/WebView.tsx`
- `MenuPage.tsx`
- `ndView.tsx`
- `lib/useWebViewRemount.ts`
- これで「どの画面がいつ active になったか」「WebView がいつ remount されたか」を追えるようにした。
## 重要な発見
### 発見 1: 走行位置画面の WebView が、非表示なのに active 扱いになる
- Sentry のイベントで、`root_tab = topMenu` なのに `positions_screen.activatedScreen = true``positions_webview.currentUrl = https://train.jr-shikoku.co.jp/sp.html` が見えていた。
- Replay 上はトップメニューだけに見えても、内部 state は走行位置側が先に活性化していた。
- これは「見た目では隠れているが、React Navigation / Fabric 的には既に mount されている」可能性を示した。
### 発見 2: `useIsFocused()` を初期 render の active 判定に使うのが危険
- `useIsFocused()` 由来の初期状態が、ナビゲータ初期化直後に想定外の `true` を返す経路があった。
- その結果、`positions` 側の root / stack / screen / webview が早すぎるタイミングで活性化した。
- これが Fabric の mount 競合を起こしている可能性が高い。
### 発見 3: `positions` の gate を 1 枚かませても足りなかった
- `DeferredPositionsRoot` を追加して root 側を遅延させたが、それでも crash は残った。
- つまり root だけではなく、stack / screen / WebView 側にも同じ「初期 focused 扱い」の問題が潜んでいた。
## 反復した修正
### 実施した instrumentation
- root navigation の state 変化を breadcrumb に残すようにした。
- `positions_stack` / `positions_screen` / `positions_webview` / `operation_screen` などの context を追加した。
- WebView remount の理由も記録するようにした。
### 実施した防御
- 画面遷移時の WebView remount を観測しやすくした。
- Android の回転時やバックグラウンド遷移時に何が起きるか追えるようにした。
## 現在の仮説
- 根本原因は React Navigation そのものではなく、初期 render 時の `focused` 判定を前提にした activation が早すぎること。
- その結果、非表示の `positions` ツリーが先に active になり、Fabric が mount state を失う。
- 画面遷移先の問題というより、「起動直後の数タイミングで走行位置を経由したタブ遷移が入ると壊れる」挙動に近い。
## upstream issue 調査メモ
- `software-mansion/react-native-reanimated#9785`
- `open`
- Android + Fabric + React Navigation stack animation 有効時に `SurfaceMountingManager` で crash。
- Android だけ stack animation を `none` にすると止まる、という回避策まで一致している。
- `software-mansion/react-native-reanimated#6908`
- `closed`
- Android Fabric で `entering/exiting animations` 使用時に大量 crash。
- 本文中に `RetryableMountingLayerException: Unable to find viewState for tag ... Surface stopped: false` があり、Sentry の症状にかなり近い。
- `software-mansion/react-native-reanimated#9636`
- `closed`
- `NativeProxy.preserveMountedTags` が Fabric の view preallocation 中に `Unable to find view for tag ... Surface stopped: false` で落ちる。
- 重い native view が同期イベントを投げると crash しやすいという話で、WebView / map 系にも連想が効く。
- `software-mansion/react-native-reanimated#8344`
- `open`
- multi-surface で `LayoutAnimationsProxy` が surface aware でない、という報告。
- 直接 Android の同一事象ではないが、「surface をまたぐ animation / mount transaction が壊れる」という意味で構造が近い。
## 現時点での対策方針
- Android の React Navigation stack animation は無効化する。
- Android の Reanimated `entering/exiting/layout` は、実際に使っている箇所から順に止めて再現率を下げる。
- 現在 repo 内で確認できた該当箇所は `components/Menu/Carousel/CarouselBox.tsx``FadeIn/FadeOut`
## 直近の修正
- `Apps.tsx`
- `DeferredPositionsRoot` の activation 初期値を `false` に変更。
- `Top.tsx`
- positions stack の activation 初期値を `false` に変更。
- `components/Apps.tsx`
- positions screen / WebView の activation 初期値を `false` に変更。
- `ndView.tsx`
- operation WebView の activation 初期値を `false` に変更。
## 直近の OTA
- branch: `gmarket`
- message: `initialize tab activation gates from false`
- update group ID: `82552333-cb5e-455f-ac58-65ada37f7acd`
- Android update ID: `019f2133-c3e6-7050-911c-49052b6b17a1`
- iOS update ID: `019f2133-c3e6-79b9-9776-88fd55ff72b8`
- commit: `cf3995fcbe34366b32b09ee1cfb6b27a7e42982c`
## いま ChatGPT に見てほしい論点
1. 初期 focused 判定を `false` にしても crash が続くなら、次はどの画面の mount ordering を疑うべきか。
2. `positions` ツリーのどこまでが本当に先に active になるのかを、Sentry context と replay でどう切り分けるか。
3. Fabric / New Architecture の mount race として扱うべきか、React Navigation の state propagation 問題として扱うべきか。
## 参考ファイル
- [Apps.tsx](/home/ubuntu/jrshikoku/Apps.tsx)
- [Top.tsx](/home/ubuntu/jrshikoku/Top.tsx)
- [components/Apps.tsx](/home/ubuntu/jrshikoku/components/Apps.tsx)
- [components/Apps/WebView.tsx](/home/ubuntu/jrshikoku/components/Apps/WebView.tsx)
- [ndView.tsx](/home/ubuntu/jrshikoku/ndView.tsx)
- [lib/useWebViewRemount.ts](/home/ubuntu/jrshikoku/lib/useWebViewRemount.ts)
## 現在の安定化パッチの意味
- 最新の `gmarket` 配信では、クラッシュ回避のために非表示タブの重い native view をかなり積極的に止めている。
- その結果、クラッシュは減った一方で、以下の UX 劣化が意図的に入っている。
- 走行位置 / 運行情報の WebView がタブ離脱後に再初期化されやすい
- タブ間で状態保持されず、開き直しに見える
- 動的ルーティングやバックグラウンド継続性が弱くなっている
- トップメニューの重い要素は遷移直後に即 mount されない
## 現在の安定基準点
- `gmarket` で配信済みの `topMenu` heavy mount 遅延パッチでは、少なくとも直近の再現テストでクラッシュ頻度は大きく下がっている。
- したがって、ここを一旦「クラッシュしにくい基準点」として扱い、以降は UX を壊している防御を一つずつ戻していく方針が妥当。
- この時点で重要なのは「全部戻す」ことではなく、「どの防御を戻すとクラッシュが再発するか」を Sentry で切り分けること。
## UX 劣化の主因になっている防御
- `components/Apps.tsx`
- `positions` の WebView を blur 時に teardown / deactivate している。
- `Apps.tsx` / `Top.tsx` / `lib/rootNavigation.ts`
- `positions` タブ離脱時に root / stack 側も落とす寄りの制御を入れている。
- `ndView.tsx`
- `information` の WebView も blur 中は deactivate している。
- `menu.tsx`
- `topMenu``MapView` / `CarouselBox` / `LED_vision` を Android で遅延 mount している。
## ここから戻す優先順位
1. `positions` の blur 時 WebView teardown を部分的に戻す
- 対象: `components/Apps.tsx`
- 理由: 走行位置の開き直し、状態喪失、バックグラウンド継続切断に最も効いているため。
2. `positions` の tab blur 時 stack / root teardown を緩める
- 対象: `Apps.tsx`, `Top.tsx`, `lib/rootNavigation.ts`
- 理由: タブ間データ共有、動的ルーティング、navigation state 維持の破綻に直結しているため。
3. `information` の blur 時 WebView teardown を戻す
- 対象: `ndView.tsx`
- 理由: 運行情報の開き直しや iOS 側の裏ブラウザ保持に関係するため。
4. `topMenu` の heavy mount 遅延を必要最小限まで縮める
- 対象: `menu.tsx`
- 理由: 初回表示のもたつき改善には効くが、状態維持より優先度は下。
5. 最終的に teardown をやめて pause ベースに置き換える
- 対象: `components/Apps.tsx`, `ndView.tsx` など
- 理由: 安定性を維持しながら UX を戻す本命だが、切り分け後にやるべき段階。
## 次の実施方針
- まず `positions` を中心に戻す。
- 1 回の変更で戻す範囲は狭くし、再現したらその差分だけを Sentry で見る。
- つまり今後の作業は「クラッシュ回避策の全撤廃」ではなく、「UX を壊している要素を優先順に切り戻し、再発ラインを特定する」ことが目的。
## 2026-07-03 時点の基準点
### 現在の評価
- 起動直後の `positions -> topMenu` / `positions -> information` で高頻度に落ちていた主クラッシュは、現時点ではかなり再現率が下がっている。
- 少なくとも直近の観測では、以前の `RetryableMountingLayerException` 系の大崩れは前面には出ていない。
- そのため、この時点は「主問題をかなり抑え込めた基準点」として保存する価値がある。
### この時点で残っている別系統の問題
- `JR-SHIKOKU-UNOFFICIAL-APPS-S`
- `Error: Unsupported top level event type "topUserLocationChange" dispatched`
- `root_tab = positions`
- `level = fatal`
- `ReactFabric-prod` 起点
- これは、今回の `topMenu / information / hidden prewarm` 系の問題とは別系統とみなす。
- つまり、この時点での主クラッシュ対策の評価をする際は、この issue は切り離して考える。
### この時点での差分の分類
#### A. 主クラッシュ抑止のコア差分
以下は、現時点の安定化に本質的に効いている可能性が高く、安易に戻さない。
- `Apps.tsx`
- `DeferredPositionsRoot`
- `hasVisitedPositions`
- startup delayed prewarm
- `detachInactiveScreens={false}`
- root tab の `positionsLifecycleRef` 連携
- `Top.tsx`
- `positions stack` の background prewarm
- stack activation gate
- `components/Apps.tsx`
- `positions screen` / `WebView` の activation gate
- `hasStableWebViewSession`
- blur 時 preserve
- unstable session 中の tab exit guard
- `components/Apps/WebView.tsx`
- WebView 初回安定化通知
- message / load / process termination の監視強化
- `lib/rootNavigation.ts`
- `positionsLifecycleRef`
- `lib/useWebViewRemount.ts`
- remount reason 付きの共通制御
#### B. 補助的な安定化差分
以下は、主因とは言い切れないが、再現率低下に寄与している可能性がある。
- `menu.tsx`
- top menu heavy content の delayed activation
- Android で `MapView` や重い要素をすぐ mount しない制御
- `ndView.tsx`
- operation WebView の delayed activation
- operation 側 remount / parse / settings load の観測
- `lib/stackOption.ts`
- Android で push animation を切る変更
- `components/Settings/settings.tsx`
- settings stack へ共通 animation option を適用
#### C. 観測専用で後から削れる差分
以下は、挙動改善というより Sentry で成功/失敗比較を行うための記録であり、安定化の評価が済めば削減可能。
- `Apps.tsx``nav.root` / `nav.tab` / `positions.root` breadcrumb と context の大半
- `Top.tsx``positions.stack` breadcrumb / context
- `components/Apps.tsx``positions.screen` breadcrumb / context
- `components/Apps/WebView.tsx``positions.webview` breadcrumb / context
- `MenuPage.tsx``topMenu.screen` breadcrumb / context
- `menu.tsx``top_menu_runtime` breadcrumb / context
- `ndView.tsx``operation.screen` / `operation.settings` / `operation.webview` breadcrumb / context
- `components/Menu/Carousel/CarouselBox.tsx``menu.carousel` breadcrumb
### この時点での重要な判断
- 今の差分は広いが、全部が同じ重みではない。
- まず守るべきなのは `positions` の activation / preserve / unstable guard 周辺であり、ここが今回の基礎。
- 一方、Sentry breadcrumb の大半は観測用なので、安定化判断後にかなり整理できる。
- `CarouselBox.tsx` は特に「Android fallback ロジック」と「breadcrumb」の両方が混ざっているため、後で整理する際は分離して扱う。
### 今後の戻し方の基準
- 戻すときは、まず観測用差分からではなく、`UX を壊しているがコアではない差分` を対象にする。
- つまり順番としては:
1. 観測を維持したまま補助差分を少し戻す
2. 再発しないことを確認する
3. 最後に breadcrumb / context を掃除する
- コア差分を先に剥がすと、再び「どこで壊れたか分からない状態」に戻るので避ける。
### この時点の結論
- `positions` の hidden / delayed activation と lifecycle guard 群は、今回の主クラッシュ対策の核として維持する。
- `topMenu` / `operation` / `carousel` 周辺の差分は、主因ではなく補助あるいは観測の可能性があるため、今後の整理対象にできる。
- 新たに見つかった `topUserLocationChange` は別問題であり、この基準点の評価には混ぜない。
## 2026-07-04 iOS WebView 保持問題の解決メモ
### 解決した現象
- iOS で `トップメニュー -> 走行位置 -> トップメニュー -> 走行位置` と遷移すると、走行位置 WebView が毎回開き直る問題。
- iOS で `トップメニュー -> 運行情報 -> トップメニュー -> 運行情報` と遷移すると、運行情報 WebView が毎回開き直る問題。
- ユーザー確認により、最新 `gmarket` OTA で iOS の上記挙動は完全に解消した。
### 解決に効いた差分
- `App.tsx`
- iOS のみ `react-native-screens` の最適化を起動時に無効化した。
- `enableFreeze(false)`
- `enableScreens(false)`
- この結果、タブ切替時に native screen 最適化層が WebView を実質的に破棄・再生成する経路を避けられた。
- したがって、iOS の WebView 保持問題については、現時点では `react-native-screens` / freeze / native screen detach 周辺が主因だったと扱う。
### 解決時点の OTA
- branch: `gmarket`
- message: `gmarket ios screens webview retention test`
- update group ID: `8aec430f-eb6d-4017-af74-c2ae7dee7fd4`
- Android update ID: `019f2dfb-69f6-72ab-8cdd-a3d1b9f4df8d`
- iOS update ID: `019f2dfb-69f6-75dd-956b-435a73519a54`
- commit: `6cea1ed4e86e30f356a7a1b5fc289f9fe1f9b5f5`
### この解決の扱い
- iOS では `react-native-screens` を無効化したため、画面保持は戻った。
- 一方で、screen 最適化を切ったことによる副作用は今後確認が必要。
- iOS の画面遷移アニメーションが重くならないか。
- トップメニューや設定画面のスクロールが再び重くならないか。
- スタック画面が増えた状態でメモリ使用量が過剰にならないか。
- ただし、今回のユーザー確認では iOS の走行位置・運行情報 WebView 保持は解決済みとして扱う。
## 2026-07-04 以降に残る検証項目
### 優先度 高: Android のタブ切替クラッシュが再発していないか
- iOS 向けの `enableScreens(false)``Platform.OS === "ios"` 限定なので、Android の挙動は直接変えていない。
- これまで Android では、走行位置の読み込み中にトップメニュー・運行情報へタブ切替すると `RetryableMountingLayerException` 系のクラッシュが再発していた。
- 確認手順:
- Android で起動直後に走行位置を開く。
- ページ読み込みが終わる前にトップメニューへ切り替える。
- 同じく、ページ読み込みが終わる前に運行情報へ切り替える。
- それぞれ複数回繰り返し、Sentry に新規 fatal が出るかを見る。
- 判定:
- fatal が出なければ、Android の現行 guard は維持。
- fatal が出るなら、Sentry breadcrumb で `positions.root` / `positions.stack` / `positions.screen` / `positions.webview` のどこまで進んだかを確認する。
### 優先度 高: iOS の解決差分の副作用確認
- iOS で screen 最適化を無効化したため、UX とメモリの確認が必要。
- 確認手順:
- 走行位置、トップメニュー、運行情報、設定画面を長時間行き来する。
- 設定画面やトップメニューの縦スクロールが重くならないか確認する。
- 運行情報の横画面表示、縦横切替、戻しを複数回試す。
- バックグラウンド復帰後に走行位置・運行情報が保持されるか確認する。
- 判定:
- 体感劣化が軽微なら、iOS の `enableScreens(false)` は維持。
- 明確な重さが出るなら、iOS だけ root tab 周辺を JS view ベースにしつつ、stack 側の screen 最適化だけ戻す案を検討する。
### 優先度 中: Android で `react-native-reanimated-carousel` を戻せるか
- 過去に Android のカクつき・クラッシュ対策として carousel 周辺を弱めた。
- ユーザー要望として、Android では `reanimated-carousel` をできれば使いたい。
- 確認手順:
- 現行の Android 安定性を先に確認する。
- その後、carousel を段階的に戻す。
- 戻す場合も Reanimated の `entering` / `exiting` / `layout` アニメーションとは分離して検証する。
- 判定:
- carousel 復帰で crash が出なければ採用。
- `RetryableMountingLayerException` が戻るなら、Android では carousel 本体または Reanimated layout animation を避ける。
### 優先度 中: Android / iOS の Sentry ノイズ整理
- 現在は切り分けのため breadcrumb / context が多い。
- 安定化判断後は、Sentry に残すものと削るものを分ける。
- 残す候補:
- root tab 遷移
- WebView remount reason
- process termination
- fatal 直前の active tab / orientation
- 削る候補:
- 通常 focus / blur の過剰な breadcrumb
- loadStart / loadEnd の頻繁な記録
- 一時的な activation gate の詳細 context
### 優先度 中: `topUserLocationChange` 系 fatal の別問題調査
- 以前確認した `JR-SHIKOKU-UNOFFICIAL-APPS-S` は今回の WebView 保持問題とは別系統として扱う。
- `Unsupported top level event type "topUserLocationChange" dispatched`
- `root_tab = positions`
- ReactFabric 起点。
- 確認手順:
- 位置情報取得中、走行位置表示中、タブ切替中のどのタイミングで出るか Sentry で再確認する。
- `useUserPosition` / MapView / location watcher 周辺を優先して見る。
### 優先度 低: トップメニュー指定起動時のバックグラウンド preload
- 過去の課題として、トップメニュー指定で起動した場合に走行位置・運行情報がバックグラウンドで読み込まれない問題が残っている。
- iOS の WebView 保持が戻ったため、再度評価する価値がある。
- 確認手順:
- `topMenu` 初期起動。
- 走行位置を開く前に、走行位置 WebView の prewarm が開始しているか Sentry context で見る。
- 運行情報も同様に hidden prewarm されるか見る。
- ただし、Android の起動直後クラッシュ再発リスクがあるため、優先度は低め。
### 優先度 低: 過去の回避策の整理
- iOS WebView 保持は `enableScreens(false)` で解決したため、それ以前に入れた一部の回避策は不要になった可能性がある。
- ただし Android のクラッシュ抑止にはまだ必要な可能性があるため、削除は Android 安定確認後に行う。
- 整理候補:
- `components/Apps/WebView.tsx` の iOS ping watchdog の必要性。
- `lib/useWebViewRemount.ts` の blur grace window の必要性。
- `Apps.tsx``PositionsTabScreen` 固定化の必要性。
- `ndView.tsx` の operation WebView delayed activation の必要性。
### 次の実務順
1. Android で走行位置読み込み中のタブ切替クラッシュが残っているか確認する。
2. iOS で `enableScreens(false)` の副作用を確認する。
3. Android が安定している場合のみ、`reanimated-carousel` 復帰検証に進む。
4. 安定確認後、Sentry breadcrumb と暫定回避策を整理する。
@@ -0,0 +1,525 @@
# Sentry観測ログ整理メモ
作成日: 2026-07-07
対象配信:
- EAS channel: `gmarket`
- Update group ID: `8fd66cda-2f4a-40e2-937f-7de8e62c72ec`
- Runtime version: `exposdk:55.0.0`
- Message: `gmarket app lifecycle crash sentinel`
## 目的
このメモは、現在のアプリがSentryへ送るはずのログ情報を、機能別に整理する。
今後、データ通信エラー、WebView白化、Sentryにfatal crashとして出ない体感クラッシュが発生したときに、どのSentry情報を見ればよいかを明確にする。
## 全体像
現在Sentryで観測できる主な系統は次の5つ。
| 系統 | 主な機能 | Sentry上の見え方 | 目的 |
|---|---|---|---|
| 通信計測 | RN側fetch wrapper | `area=data_fetch` のIssue、breadcrumb、span | endpoint別の失敗、timeout、HTTP status、JSON parse失敗を追う |
| アプリライフサイクル | crash sentinel | `app.previous_session_unexpected_exit` | fatal crashとして出ない突然終了を次回起動で検知する |
| root navigation | タブ遷移、root状態 | `root_navigation` context、`nav.root`/`nav.tab` breadcrumb | どのタブ、どの遷移直後に問題が起きたかを追う |
| WebView状態 | 走行位置、運行情報、hidden preload | `active_webviews` context、各WebView breadcrumb | クラッシュ/白化直前にどのWebViewが生きていたかを見る |
| 画面ゲート/プリロード | positions/information lazy activation | `positions_root_gate` / `operation_root_gate` context、root breadcrumb | preloadや初回activationのタイミング問題を追う |
## 1. 通信計測 data_fetch
実装場所:
- `lib/observability/network/observedFetch.ts`
- `lib/observability/network/sentryNetwork.ts`
- `lib/observability/network/endpoints.ts`
- `lib/observability/network/types.ts`
### 何をしているか
RN側の通信を `observedFetchJson` / `observedFetchText` 経由にして、endpointごとに次を記録する。
- fetch開始 breadcrumb
- 成功/失敗 breadcrumb
- `http.client` span
- 失敗時の `captureException`
- timeout時の primary error: `ObservedFetchError("Network request timed out")`
- JSON期待時のHTML返却検知
- JSON parse失敗検知
- retry回数
- appState / online状態
- URL全文ではなく host + path template
### Sentry tags
通信系Issueには主に次のtagが付く。
| tag | 値 | 用途 |
|---|---|---|
| `area` | `data_fetch` | 通信計測イベントの絞り込み |
| `endpoint` | 低カーディナリティendpoint名 | どの論理取得か |
| `result` | `timeout`, `network_error`, `http_error`, `non_json`, `parse_error`, `empty_response`, `success`, `slow_success`, `aborted` | 結果分類 |
| `root_tab` | `positions`, `topMenu`, `information`, `unknown` | 発生時のroot tab |
| `platform` | `ios`, `android` | OS差分 |
| `source` | `rn_fetch`, `mock_api`, `n8n`, `gas`, `backend_api`, `static_storage`, `webview_fetch` | 取得元分類 |
| `user_visible` | `true` / `false` | ユーザー体感に直結する取得か |
| `preload` | `true` / `false` | preload扱いか |
| `fetch_priority` | `high`, `medium`, `low` | 優先度 |
| `status` | HTTP status文字列 | HTTP系の絞り込み |
### Sentry context: `data_fetch`
| context field | 内容 |
|---|---|
| `kind` | 失敗分類。captureException時に付く |
| `endpoint` | 論理endpoint |
| `method` | 現状ほぼ `GET` |
| `status` | HTTP status |
| `ok` | response.ok |
| `durationMs` | 通信 + body read時間 |
| `timeoutMs` | endpointごとのtimeout |
| `bytes` | response body推定サイズ |
| `contentType` | response content-type |
| `responseHead` | 失敗時のみ最大300文字 |
| `retryCount` | `0` または `1` |
| `urlHost` | hostのみ |
| `urlPathTemplate` | path templateのみ |
| `appState` | `active`, `background`, `inactive` など |
| `online` | `navigator.onLine` が取れた場合のオンライン状態 |
### breadcrumb
| category | message | level | 意味 |
|---|---|---|---|
| `data_fetch` | `fetch:start` | `info` | fetch開始 |
| `data_fetch` | `fetch:success` | `info` | 成功 |
| `data_fetch` | `fetch:slow_success` | `warning` | slowMs超過。ただしIssue化は現在無効 |
| `data_fetch` | `fetch:timeout` | `error` | timeout |
| `data_fetch` | `fetch:network_error` | `error` | fetch reject |
| `data_fetch` | `fetch:http_error` | `error` | HTTP 4xx/5xx |
| `data_fetch` | `fetch:non_json_response` | `error` | JSON期待だがJSONに見えない |
| `data_fetch` | `fetch:json_parse_error` | `error` | JSON.parse失敗 |
| `data_fetch` | `fetch:empty_response` | `error` | 空レスポンス |
| `data_fetch` | `fetch:aborted` | `info` | background/offlineで開始抑制。Issue化しない |
### span
`Sentry.startSpan` が使える環境では次のspanを出す。
| field | 値 |
|---|---|
| `op` | `http.client` |
| `name` | `fetch <endpoint>` |
| `description` | `fetch <endpoint>` |
| `app.area` | `data_fetch` |
| `app.endpoint` | endpoint名 |
| `app.root_tab` | root tab |
| `http.method` | method |
| `server.address` | URL host |
注意:
- 自作span名には実URLを入れない。
- Sentry SDKの自動HTTP spanに実URLが出る可能性はある。
### Issue化ルール
| 結果 | Issue化 | level | 備考 |
|---|---|---|---|
| success | しない | breadcrumb/spanのみ | 正常系ノイズ防止 |
| slow_success | 現在しない | warning予定 | `SLOW_SUCCESS_SAMPLE_RATE = 0` |
| timeout | する | warning | `ObservedFetchError("Network request timed out")` |
| network_error | active/onlineならする | warning | background/offline由来は抑制 |
| http_error | する | warning | status付き |
| non_json_response | する | warning | HTML返却調査用にresponseHead |
| json_parse_error | する | error | 実装/データ形式不一致の疑い |
| empty_response | する | warning | 空body |
| aborted | しない | breadcrumbのみ | background/offline開始抑制 |
## 2. 現在の通信endpoint一覧
| endpoint | source | 機能 | 画面/利用箇所 | URL host/path | timeout | retry | user_visible | preload | priority |
|---|---|---|---|---|---:|---|---|---|---|
| `positions_current` | `n8n` | 現在走行位置 | 走行位置、列車詳細、発車標系 | `n8n.haruk.in` `/webhook/c501550c-7d1b-4e50-927b-4429fe18931a` | 8000ms | 最大1回 | true | false | high |
| `positions_current` | `mock_api` | mock現在走行位置 | mock有効時の走行位置 | `jr-shikoku-backend-mock-api-v1.haruk.in` `/train-positions/current` | 8000ms | 最大1回 | true | false | high |
| `positions_master` | `mock_api` | mock位置マスタ | mock有効時の補完データ | `jr-shikoku-backend-mock-api-v1.haruk.in` `/position-masters` | 8000ms | 最大1回 | false | true | medium |
| `positions_gas_fallback` | `gas` | 走行位置fallback | n8n失敗時のみ | `script.google.com` GAS `/exec` | 8000ms | 最大1回 | true | false | high |
| `operation_info_flag` | `n8n` | 運行情報フラグ/対象エリア | topMenu、information、badge | `n8n.haruk.in` `/webhook/jr-shikoku-trainfo-flag` | 10000ms | 最大1回 | true | true | medium |
| `operation_info_text` | `gas` | 運行情報本文補助 | operation flagで対象ありの場合のみ | `script.google.com` GAS `/exec` | 15000ms | なし | true | false | medium |
| `operation_logs` | `backend_api` | 列車別運行ログ | provider全体、列車詳細、WebView補助と重複 | `jr-shikoku-backend-api-v1.haruk.in` `/operation-logs` | 15000ms | 最大1回 | false | true | medium |
| `train_operation_data` | `backend_api` | 編成/運用系データ | provider全体、列車詳細、走行位置WebView補助と重複 | `jr-shikoku-backend-api-v1.haruk.in` `/train-data` | 15000ms | 最大1回 | false | true | medium |
| `timetable_today` | `static_storage` | 当日ダイヤJSON | provider全体、時刻表/発車標/列車詳細 | `jr-shikoku-api-data-storage.haruk.in` `/tmp/diagram-today.json` | 15000ms | 最大1回 | false | true | medium |
## 3. アプリライフサイクル crash sentinel
実装場所:
- `lib/observability/appLifecycleCrashSentinel.ts`
- `App.tsx`
- `Apps.tsx`
- `components/Apps/WebView.tsx`
- `ndView.tsx`
### 何をしているか
Sentryに `fatal`, `handled:false`, `event.type:crash` として出ない「ユーザー体感上の完全クラッシュ」を、次回起動時に検出する。
AsyncStorageに以下を保存する。
- `app_session_active=true`
- `normal_background`
- `sessionStartedAt`
- `lastHeartbeatAt`
- `lastKnownAppState`
- `lastRootTab`
- `previousRootNavigation`
- `activeWebViews`
- `nativeScreensMode`
稼働中は7秒ごとに `lastHeartbeatAt` を更新する。
AppStateが `background` になったら `normal_background=true` を記録する。
次回起動時、前回sessionが次を満たす場合だけ `app.previous_session_unexpected_exit` を送る。
- `app_session_active=true`
- `normal_background=false`
- `lastHeartbeatAt` が直近120秒以内
- sessionが5秒以上続いていた
- 同じ `sessionStartedAt` で未報告
### Sentry event
| event | level | 目的 |
|---|---|---|
| `app.previous_session_unexpected_exit` | warning | fatal crashとして出ない突然終了を補足 |
### tags
| tag | 内容 |
|---|---|
| `area=app_lifecycle` | lifecycle系の絞り込み |
| `result=unexpected_exit` | 前回session異常終了 |
| `platform` | `ios` / `android` |
| `root_tab` | 起動時に把握できる現在root tab |
| `last_root_tab` | 前回session最後のroot tab |
| `native_screens_mode` | `screens-disabled` or `default` |
### context: `app_lifecycle`
| field | 内容 |
|---|---|
| `lastHeartbeatAt` | 前回session最後のheartbeat |
| `sessionStartedAt` | 前回session開始時刻 |
| `previousRootNavigation` | 前回最後に観測したroot/nested navigation |
| `lastKnownAppState` | 前回最後のAppState |
| `activeWebViews` | 前回最後にactive扱いだったWebView名 |
| `memory` | `performance.memory` があればJS heap情報。RNではnullの可能性あり |
| `expoUpdate` | `update_id`, `updateId`, `channel`, `runtimeVersion`, `createdAt`, `isEmbeddedLaunch` |
| `normalBackground` | 前回正常background遷移したか |
| `heartbeatAgeMs` | 現在から見た前回heartbeatの古さ |
### 関連context
| context | 内容 |
|---|---|
| `app_lifecycle_sentinel` | 現sessionの開始、heartbeat、appState、activeWebViews、expoUpdate |
| `active_webviews` | 現在active扱いのWebView名一覧 |
### activeWebViews名
| name | 意味 |
|---|---|
| `positions_main` | 走行位置タブ本体のWebView |
| `operation_info_main` | 運行情報タブ本体のWebView |
| `startup_hidden_positions` | 起動時hidden preloadの走行位置WebView |
| `startup_hidden_operation` | 起動時hidden preloadの運行情報WebView |
## 4. root navigation / tab操作ログ
実装場所:
- `Apps.tsx`
- `lib/rootNavigation.ts`
### Sentry context: `root_navigation`
| field | 内容 |
|---|---|
| `rootIndex` | root tab index |
| `rootRoute` | `positions`, `topMenu`, `information` |
| `nestedIndex` | nested stack index |
| `nestedRoute` | nested route名 |
| `hasExtra` | 追加画面/stackが開いているか |
| `source` | `ready` or `change` |
| `operationOrientation` | `landscape` or `portrait` |
| `hasVisitedPositions` | positions rootをactivate済みか |
| `hasVisitedInformation` | information rootをactivate済みか |
### tags
| tag | 内容 |
|---|---|
| `root_tab` | 最後に観測したroot tab |
| `native_screens_mode` | iOSは `screens-disabled`、Androidは `default` |
### breadcrumb
| category | message | 意味 |
|---|---|---|
| `runtime.flags` | `runtime navigation flags applied` | native screens/freeze設定 |
| `nav.root` | `navigation ready` | NavigationContainer ready |
| `nav.root` | `root state initialized` | 初期root state |
| `nav.root` | `root state change` | root state変化 |
| `nav.tab` | `root tabPress` | タブ押下 |
| `nav.tab` | `root tabPress intercepted for unstable positions` | 不安定な走行位置離脱を検知 |
| `nav.tab` | `root tabPress blocked while positions unstable` | 不安定な走行位置離脱をブロック |
| `nav.tab` | `root tab focus` | root tab focus |
| `nav.tab` | `root tab blur` | root tab blur |
| `nav.tab` | `root tab state` | tab state event |
## 5. root gate / preloadログ
実装場所:
- `Apps.tsx`
### context
| context | 内容 |
|---|---|
| `positions_root_gate` | `focused`, `activated`, `shouldActivate` |
| `operation_root_gate` | `focused`, `activated`, `shouldActivate` |
| `startup_hidden_preload` | hidden preloadのshould/loaded状態 |
### breadcrumb
| category | message | 意味 |
|---|---|---|
| `positions.root` | `positions startup prewarm scheduled` | topMenu起動中にpositions prewarm予約 |
| `positions.root` | `positions startup prewarm activated` | positions prewarm実行 |
| `positions.root` | `positions root activation` | positions root初回activate |
| `positions.root` | `positions root tabPress activation` | positionsタブ押下でactivate |
| `positions.root` | `positions root focus activation` | positions focusでactivate |
| `positions.root` | `positions root blur preserved` | blur後も保持 |
| `positions.root` | `positions root exit blocked while session unstable` | 不安定セッションからの離脱ブロック |
| `operation.root` | `operation startup prewarm scheduled` | information prewarm予約 |
| `operation.root` | `operation startup prewarm activated` | information prewarm実行 |
| `operation.root` | `operation root activation` | operation root初回activate |
| `operation.root` | `operation root tabPress activation` | informationタブ押下でactivate |
| `operation.root` | `operation root focus activation` | information focusでactivate |
| `operation.root` | `operation root blur preserved` | blur後も保持 |
| `startup.preload` | `positions hidden preload loadStart/loadEnd/error` | hidden positions WebViewのロード状態 |
| `startup.preload` | `operation hidden preload loadStart/loadEnd/error` | hidden operation WebViewのロード状態 |
## 6. 走行位置WebViewログ
実装場所:
- `components/Apps/WebView.tsx`
- `lib/useWebViewRemount.ts`
### context: `positions_webview`
| field | 内容 |
|---|---|
| `focused` | screen focus状態 |
| `landscape` | 横画面か |
| `mockApi` | mock API有効か |
| `remountKey` | WebView remount key |
| `currentUrl` | WebView現在URL。取れていない場合null |
### breadcrumb category: `positions.webview`
| message | 意味 |
|---|---|
| `webview focused` / `webview blurred` | focus変化 |
| `webview pending initial inject resumed on focus` | focus復帰時に保留injectを実行 |
| `webview remount key` | remount key変化 |
| `webview remount requested` | WebView watchdog等でremount要求 |
| `webview cleanup start` | unmount/cleanup開始 |
| `webview unmounted` | unmount完了 |
注意:
- 公式サイト内XHR `/g?arg1=train&arg2=train` はRN側fetch wrapperを通らないため、現状 `data_fetch` には出ない。
- WebView内部の公式通信や injected JS fetch は、現時点ではbreadcrumb/postMessage中心で、通信詳細は未計測。
## 7. 運行情報WebViewログ
実装場所:
- `ndView.tsx`
- `lib/useWebViewRemount.ts`
### context: `operation_screen`
| field | 内容 |
|---|---|
| `focused` | information画面focus |
| `orientation` | `portrait` / `landscape` |
| `landscapeMode` | 運行情報横画面モード有効か |
| `captureEnabled` | 画像切り出し有効か |
| `activatedWebView` | WebView activate済みか |
| `layoutWidth` / `layoutHeight` | root layout |
| `contentTop` | safe area反映後top |
| `signageSafeLeft` / `signageSafeRight` | 横画面safe area |
| `remountKey` | WebView remount key |
### breadcrumb
| category | message | 意味 |
|---|---|---|
| `operation.screen` | `operation screen mounted/unmounted` | 画面mount状態 |
| `operation.screen` | `operation focused/blurred` | focus変化 |
| `operation.screen` | `operation layout` | layout/orientation変化 |
| `operation.screen` | `operation webview activation scheduled` | WebView activate予約 |
| `operation.screen` | `operation webview activated` | WebView activate実行 |
| `operation.screen` | `operation appState` | AppState変化 |
| `operation.settings` | `operation settings load scheduled/success/failed` | 運行情報設定の読込 |
| `operation.webview` | `operation remount requested` | watchdog等のremount要求 |
| `operation.webview` | `operation remount after background resume` | background復帰後remount |
| `operation.webview` | `operation resume preserved existing webview` | background復帰後WebView保持 |
| `operation.webview` | `operation script scheduled/injected` | injected script投入 |
| `operation.webview` | `operation render process gone` | Android WebView render process gone |
| `operation.webview` | `operation content process terminated` | iOS WebView content process termination |
| `operation.webview` | `operation webview loadStart/loadEnd/error` | WebView load状態 |
| `operation.webview` | `operation capture message received` | WebViewから画像切り出しmessage |
| `operation.webview` | `operation message parse failed` | postMessage parse失敗 |
## 8. 調査時の優先クエリ
### 通信エラー全体
```text
area:data_fetch
```
### 走行位置の体感不具合
```text
area:data_fetch endpoint:positions_current
```
見る項目:
- `source`
- `result`
- `durationMs`
- `timeoutMs`
- `retryCount`
- `root_tab`
- `appState`
- `online`
- `urlHost`
- `urlPathTemplate`
### GAS運行情報timeout
```text
area:data_fetch endpoint:operation_info_text result:timeout
```
見る項目:
- `durationMs`
- `retryCount`
- `root_tab`
- `user_visible`
- `preload`
- `appState`
### background/offlineノイズ
```text
area:data_fetch result:aborted
```
原則Issue化されない。breadcrumbで確認する。
### JSONではなくHTMLが返ったケース
```text
area:data_fetch result:non_json
```
見る項目:
- `contentType`
- `responseHead`
- `status`
- `urlHost`
- `urlPathTemplate`
### Sentryにfatal crashが出ない体感クラッシュ
```text
area:app_lifecycle result:unexpected_exit
```
見る項目:
- `last_root_tab`
- `app_lifecycle.lastHeartbeatAt`
- `app_lifecycle.previousRootNavigation`
- `app_lifecycle.lastKnownAppState`
- `app_lifecycle.activeWebViews`
- `app_lifecycle.expoUpdate.update_id`
- `native_screens_mode`
### WebView白化/終了疑い
```text
operation.webview
positions.webview
```
breadcrumbで見る。
関連context:
- `positions_webview`
- `operation_screen`
- `active_webviews`
- `app_lifecycle`
## 9. 機能別に何が分かるか
| 機能 | Sentryで分かること | まだ分からないこと |
|---|---|---|
| RN走行位置取得 | n8n/mock/GASのどれが失敗したか、timeout/HTTP/parse、duration、retry | 上流サーバー内部の処理時間。backend側計測が必要 |
| RN運行情報取得 | flag/GAS text/operation logsのどれが失敗したか | 公式WebViewページ内部の通信詳細 |
| ダイヤ/運用取得 | static storage/backend APIの失敗、遅延、サイズ | WebView内の重複fetch詳細 |
| WebView保持 | focus/blur、remount、process gone、content termination、activeWebViews | WebView内fetchのstatus/durationは未計測 |
| タブ切替 | tabPress/focus/blur、root state、root_tab | native側のFabric mount詳細 |
| 体感クラッシュ | 次回起動時に前回sessionの突然終了をwarning化 | OS killとnative crashの厳密な区別 |
## 10. 現時点の注意点
- 成功通信はIssue化しない。breadcrumb/spanで見る。
- `slow_success` のIssue送信は現在 `SLOW_SUCCESS_SAMPLE_RATE = 0` のため無効。
- background/offline由来の通信失敗はIssue化を抑制している。
- URL全文はtagに入れない。host/path templateのみcontextに入る。
- response body全文は送らない。`responseHead` は最大300文字。
- WebView内の公式XHRや injected JS fetch は、RN `observedFetch` を通らないため通信詳細が見えない。
- `app.previous_session_unexpected_exit` は「前回sessionが直近heartbeatのまま正常backgroundなしで消えた」ことを示す。Sentry native crashそのものではない。
## 11. 次に増やすとよい計測
優先度 high:
- WebView内fetchのpostMessage計測。
- 特に `positions_webview` と公式XHR `/g?arg1=train&arg2=train`
- backend mock API `/train-positions/current` のサーバー側duration、cache hit/miss、upstream duration。
優先度 medium:
- `useBusAndTrainData`, `useTrainDelayData`, `SpecialTrainInfoBox` など未計測RN fetchのobservedFetch化。
- `operation.webview` のpostMessageにページ状態 summary を追加。
- `activeWebViews``GeneralWebView` も必要に応じて追加。
優先度 low:
- slow success samplingを `0.01` から再開し、十分落ち着いたら調整。
- cellular/connection type取得。現状は `navigator.onLine` 程度。
@@ -0,0 +1,232 @@
# アプリ向けVoicepeak公開音声生成エンドポイントの追加依頼
## 概要
JR四国非公式アプリでは、駅の発車標データなどをもとに「りっかちゃん」の案内原稿を生成し、Voicepeak APIから取得した音声を再生しています。
現在は、利用者がアプリの設定画面で以下を入力する構成です。
- Voicepeak APIのベースURL
- Bearerトークン
- 話者名
機能が実用段階に入ってきたため、一般利用者によるAPI設定を廃止し、アプリから直接利用できる公開エンドポイントを用意したいと考えています。
共通BearerトークンをアプリやEAS Updateへ組み込むと、アプリバンドルから抽出できてしまいます。そのため、既存の管理用APIは認証付きのまま維持し、制限されたアプリ専用エンドポイントを認証なしで追加する方針です。
## 希望する構成
同じVoicepeakサーバー内で、用途別に2つのエンドポイントを提供します。
```text
JR四国非公式アプリ
│ 認証なし
POST /v1/public/speech
│ りっかちゃん固定・入力制限・レート制限・キャッシュ
Voicepeak音声生成処理
管理・開発ツール
│ Bearer認証
POST /v1/speech
│ 既存機能を維持
Voicepeak音声生成処理
```
サーバーを2台に分ける意図ではありません。既存サーバー内に公開用の入口を追加し、管理用APIと権限・機能を分離する想定です。
## エンドポイント案
### アプリ向け
```http
POST /v1/public/speech
Content-Type: application/json
```
認証は要求しません。
リクエスト例:
```json
{
"text": "次の、2番線に参ります列車は、12時34分発、特急しおかぜ、岡山行きです。",
"format": "mp3"
}
```
レスポンス:
- 成功時は音声バイナリを返す
- `format: "mp3"`の場合は`Content-Type: audio/mpeg`
- `format: "wav"`の場合は`Content-Type: audio/wav`
話者はリクエストから指定させず、サーバー側で「りっかちゃん」に固定することを希望します。
後方互換性の都合で`speaker`を受け取る場合も、公開エンドポイントでは値を無視するか、許可されたりっかちゃんの識別子以外を拒否してください。
### 管理・開発向け
```http
POST /v1/speech
Authorization: Bearer <secret>
Content-Type: application/json
```
こちらは現在の仕様とBearer認証を維持します。話者変更など、公開APIに不要な機能は管理用APIだけで提供します。
## 公開エンドポイントに必要な制限
### 必須
- 原稿は空文字を拒否する
- 原稿は最大140文字とする
- リクエスト本文全体のサイズ上限を設ける
- 利用可能な形式を`mp3``wav`に限定する
- 話者をりっかちゃんに固定する
- Voicepeakプロセスへの同時実行数を制限する
- 上限を超えた生成要求はサーバー内キューで順番に処理する
- IPなどを利用したレート制限を設ける
- タイムアウトを設定する
- 管理用APIや管理画面は公開APIから到達できないようにする
- 制限値は環境変数などで変更可能にする
### 推奨
- 同一原稿の生成結果をキャッシュする
- キャッシュキーに正規化後の原稿、形式、話者、音声モデルのバージョンを含める
- キャッシュの最大容量と有効期限を設定する
- リクエスト数、成功数、失敗数、生成時間、キュー待ち時間を計測する
- 公開APIだけを即時停止できる緊急停止スイッチを設ける
- 異常なアクセス元を一時的に遮断できるようにする
## 文字数の数え方
アプリ側では、半角カタカナをNFKC正規化して全角へ変換したあと、135文字以内になるように原稿を分割しています。140文字ぎりぎりではなく、5文字分の余裕を設けています。
サーバー側で以下を明文化してもらえると助かります。
- 正規化前と正規化後のどちらで140文字を判定するか
- Unicodeコードポイント、UTF-16コード単位、UTF-8バイト数のどれを「文字数」とするか
- 改行や空白を文字数へ含めるか
アプリとの不一致を防ぐため、サーバー側でもNFKC正規化後の文字列を判定対象にする案を希望します。
## 現在確認している並列生成上の懸念
長い案内は、アプリ側で複数の135文字以内の原稿へ分割します。現在のアプリ実装では、再生前にすべての音声を取得するため、分割されたリクエストを同時送信する場合があります。
事前案内だけ再生されない現象があり、Voicepeakサーバーが複数の音声生成を同時に受けた際、その一部を拒否している可能性を調査しています。
公開エンドポイントでは、複数リクエストを受けてもVoicepeakプロセスへ安全に直列化できるキューを希望します。
確認したい項目:
- 現在のサーバーが同時生成を何件まで処理できるか
- 同時実行上限を超えた場合の現在の挙動
- 待機させる場合の最大キュー長
- キュー満杯時に返すHTTPステータス
- クライアント側で推奨される再試行方法
アプリ側も必要に応じて、分割音声を1件ずつ順番に取得する方式へ変更できます。
## エラーレスポンス案
エラー時は、アプリのデバッグログで原因を識別できるJSONレスポンスを希望します。
```json
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many speech generation requests",
"retryAfterSeconds": 10,
"requestId": "..."
}
}
```
ステータスコード案:
| ステータス | 用途 |
|---|---|
| `400` | JSON形式、必須項目、形式指定などが不正 |
| `413` | リクエスト本文が大きすぎる |
| `422` | 原稿が空、または140文字を超えている |
| `429` | レート制限、または一時的な生成上限 |
| `500` | サーバー内部エラー |
| `503` | Voicepeak停止中、キュー満杯、メンテナンス中 |
`429`または`503`の場合は、可能であれば`Retry-After`ヘッダーも返してください。
## ログとプライバシー
原稿には駅名、列車名、時刻、行き先、運行状況などが含まれます。通常は個人情報を含みませんが、自由入力値が混入する可能性を完全には排除できません。
サーバーログでは以下を推奨します。
- Authorizationヘッダーや管理用シークレットを絶対に記録しない
- 原稿本文を保存する場合は保持期間を決める
- 通常の計測は原稿ハッシュ、文字数、生成時間、結果だけでも行えるようにする
- リクエストごとに追跡用`requestId`を発行する
アプリ側にはVoicepeakデバッグログ機能を追加済みです。
- 送信原稿
- 文字数
- 分割番号
- HTTPステータス
- 処理時間
- 応答サイズ
- エラー内容
- OSとRuntime Version
を端末内へ最大7日間保存できます。APIトークンは記録していません。
## アプリ側の移行予定
公開エンドポイントの準備完了後、JR四国非公式アプリ側で以下を変更します。
1. 音声生成先を`/v1/public/speech`へ変更
2. `Authorization`ヘッダーを送信しない
3. Voicepeak API URL入力欄を削除
4. APIトークン入力欄を削除
5. 話者名入力欄を削除
6. 既存端末に保存されたAPIトークンを削除
7. 音声案内の有効・無効設定は維持
8. Voicepeakデバッグログ機能は維持
9. 必要に応じて分割音声の取得を直列化
公開エンドポイントのパスが確定するまでは、現在の認証付き`/v1/speech`を継続利用します。
## 受け入れ条件
- 認証なしで`POST /v1/public/speech`からりっかちゃんの音声を取得できる
- 管理用`POST /v1/speech`は引き続きBearer認証が必要
- 公開APIから話者を変更できない
- 140文字以内の日本語原稿をMP3とWAVで生成できる
- 141文字以上の原稿が明確なエラーで拒否される
- 不正な形式指定が拒否される
- 複数リクエストが到着してもVoicepeakプロセスが競合しない
- レート制限時にクライアントが判別可能なエラーを返す
- 管理用シークレットがレスポンスやログへ露出しない
- 公開APIだけを停止できる
## Voicepeak開発側へ確認したい事項
1. 公開エンドポイントを同一サーバーへ追加できるか
2. `/v1/public/speech`というパスで問題ないか
3. 公開APIで利用する正式な話者識別子
4. 140文字制限の具体的な判定方法
5. MP3とWAVの両方を公開APIで許可できるか
6. 現在の同時生成制限と、サーバーキューの実装可否
7. 適切なレート制限値
8. キャッシュの実装可否
9. エラー形式と`requestId`の付与可否
10. 公開予定日と、アプリ側が切り替えてよいタイミング
@@ -0,0 +1,153 @@
# X投稿向け運行情報画像セット:実装指示書
## 依頼
`ndView.tsx` に、X(旧Twitter)へ複数画像で投稿することを前提とした運行情報画像セット生成機能を実装してください。
既存の「この項目を切り出す」「この小見出しを切り出す」「表示中の運行情報をすべて切り出す」「項目ごとに複数画像で共有」は残し、新たに「X投稿向け画像を作成」を追加します。
実装まで行い、型チェックと生成処理の確認をしてください。調査・提案だけで終了しないでください。
## ユーザー体験
新しいボタンを押すと、次の順番で最大4枚のPNGを生成し、既存の複数ファイル共有処理へ渡します。
1. 1枚目:運行状況の表紙(路線図+短い要約)
2. 2枚目以降:表示中の運行情報の詳細
Xで1枚目と2枚目が横並びに表示されたときに自然につながって見える構成にします。ただし、各画像は単体表示されても意味が通じる必要があります。画像の境界をまたいで文章や必須情報を配置しないでください。
## 出力仕様
- 各画像は `1080 x 1440 px`3:4)の固定サイズ
- PNG
- 画像数は1〜4枚。運行情報が存在する通常ケースでは表紙+詳細で2〜4枚
- ファイル名は投稿順を明確にする
- `operation-info-x-01-map-<timestamp>.png`
- `operation-info-x-02-detail-<timestamp>.png`
- 以下同様
- ネイティブ側へ送る `batchIndex` はゼロ始まりの表示順と一致させる
- 既存の `Share.open({ urls })` に渡る配列順を維持する
- 画像生成途中で1枚でも失敗した場合、不完全なセットを共有せず既存形式のエラーを返す
## 1枚目:表紙
必須要素:
- `JR四国 列車運行情報` のタイトル
- 現在表示中の状態を示す短い要約
- 四国の路線図
- 更新日時(取得できる最新のもの)
- 詳細ページがある場合は `詳細は次の画像へ →`
- 非公式アプリによる生成画像であり、最新情報はJR四国公式を確認する旨
- ページ番号(例:`1/4`
路線図は既存の `buildMapImage()``img/map/*` を再利用します。現在ページ上で有効になっている路線レイヤーを反映してください。既存挙動を壊さない範囲で、影響路線を目立たせ、影響のない部分を弱められるなら実装してください。状態と路線の対応を正確に判定できない場合は、誤った色分けを推測せず、現在表示されているレイヤー表現を使用してください。
表紙は路線図を主役にし、要約文が長い場合も地図を極端に小さくしないでください。要約は路線名・状態を中心に短く整形し、詳細本文を表紙へ詰め込みません。
## 2枚目以降:詳細ページ
必須要素:
- 路線・項目タイトル
- サブタイトル/状態
- 更新日時
- 小見出しと本文
- ページ番号
- 非公式画像の注意書き
既存の `getOperationItems()``parseDetailBlocks()``splitCaptureBlocksIntoSections()` のデータ構造を再利用します。
詳細は「1項目=必ず1画像」ではなく、固定高のページへ順に組版してください。
- 1件が短ければ、同じページに次の項目も配置してよい
- 1件が長ければ、ブロックまたは小見出し単位で次ページへ送る
- 小見出しだけをページ末尾に残さない
- 本文を文字単位で不自然に分断するより、小見出し単位の改ページを優先する
- 4枚を超える場合は、詳細3ページへ読みやすく収める
- それでも収まらない場合は、文字を極端に縮小したり内容を黙って欠落させたりしない
4枚で収まらないケースについては、実装上安全な扱いを選び、コードコメントと完了報告で明示してください。推奨は、最小可読サイズを守ったうえで4枚では収まらないことをユーザーへ知らせ、既存の項目別共有を案内することです。
## レイアウト規則
- 背景は白を基本とし、既存のJR四国風配色(`#0099CB` など)を継承
- 左右80px程度をX側のクロップに備えた安全領域とする
- 必須情報は端へ寄せない
- 全ページでヘッダー、ページ番号、フッターの位置を統一
- 本文の可読性を優先し、既存画像と同程度の文字サイズを維持
- 1枚目右側の矢印などで2枚目への視線誘導を作ってよいが、その装飾が切れても意味が失われないようにする
- 角丸や画像間の隙間はX側が付けるため、画像自体には外周の角丸を焼き込まない
## 既存コードの入口
主な対象は `ndView.tsx` です。
- `buildOperationPageScript()`WebViewへ注入する生成処理
- `buildMapImage()`:路線図Canvas合成
- `appendCaptureItemLayout()` / `drawCaptureLayout()`:既存組版・描画
- `getOperationItems()`:表示中の運行情報抽出
- `installPageCaptureButton()`:共有ボタン追加
- `renderAllItemsToSeparateImages()`:既存バッチ送信の参考
- `saveOperationCaptureBatchItem()`:ネイティブ側で順番どおりにファイルを集約
- `shareOperationCaptureFiles()`:単一/複数ファイル共有
必要ならX投稿向けのレイアウト計算・描画関数を独立させてください。既存の可変幅・可変高画像生成ロジックへ多数の条件分岐を足すより、データ抽出と基本描画だけ共有する構成を優先します。
## 実装上の制約
- 既存の切り出し/共有機能を壊さない
- 横画面サイネージモードでは、従来どおりキャプチャボタンを表示しない
- iOS・Android双方で動く既存のWebViewメッセージ方式を維持
- 新しい依存パッケージは原則追加しない
- WebView内で利用できない新しいJavaScript構文を安易に導入しない
- 生成中の二重タップで複数バッチが競合しないよう考慮する
- ユーザーの既存変更があるファイルを勝手に巻き戻さない
- `scripts/generate-operation-info-userscript.mjs``ndView.tsx` の注入コードを元に生成物を作るため、必要なら生成物も更新する。ただし生成物を直接手編集しない
## UI文言
新規ボタン:
`X投稿向け画像を作成`
生成・共有に失敗した場合は、既存の「切り出し失敗」系ダイアログとトーンをそろえます。4枚へ収まらない場合は、理由と代替手段が分かる日本語を表示してください。
## 完了条件
- 新規ボタンから表紙+詳細画像が正しい順番で共有される
- 全画像が厳密に1080×1440
- 1件、2件、3件、4件以上、長文1件のレイアウトを確認している
- 長文がCanvas外へはみ出さない
- 空のタイトル、サブタイトル、更新日時でも壊れない
- 地図画像のロード失敗時も白紙画像を共有せず、明確に失敗扱いまたは安全なフォールバックになる
- 既存4種類の切り出し操作が維持される
- TypeScript型チェックが通る
- `git diff --check` が通る
## 検証
最低限、次を実行してください。
```bash
npx tsc --noEmit
git diff --check
```
注入スクリプトを変更した場合は、リポジトリ既存の生成手順も確認します。
```bash
node scripts/generate-operation-info-userscript.mjs
```
可能なら、Canvas描画を開発用に呼び出せる小さなテスト入口または純粋なページ分割関数を用意し、上記の件数・長文パターンを検証してください。本番UIへデバッグ操作を残さないでください。
## 完了報告に含めるもの
- 変更したファイル
- 表紙・詳細ページの分割ルール
- 4枚で収まらない場合の扱い
- 実行した検証と結果
- 端末で確認が必要な残件
+6
View File
@@ -47,6 +47,12 @@
}, },
"production7.0": { "production7.0": {
"channel": "familymart" "channel": "familymart"
},
"beta7.1": {
"channel": "gmarket"
},
"production7.1": {
"channel": "geekbuying"
} }
}, },
"submit": { "submit": {
+6
View File
@@ -72,6 +72,9 @@ export type CustomTrainData = {
isThrough: boolean; isThrough: boolean;
platformNum: string | null; platformNum: string | null;
se?: string; se?: string;
isOrigin?: boolean;
arrivalTime?: string;
departureTime?: string;
}; };
export type StationProps = { export type StationProps = {
@@ -89,6 +92,9 @@ export type CustomTrainData = {
lng: number; lng: number;
isSpot?: boolean; isSpot?: boolean;
}; };
export type OriginalStationList = Record<string, StationProps[]>;
export type OperationLogs = { export type OperationLogs = {
id: number; id: number;
operation_id?: string; operation_id?: string;
+176
View File
@@ -0,0 +1,176 @@
import {
hasNotificationSound,
saveNotificationSound,
scheduleLocationAnnouncements,
type LocationAnnouncement,
} from "expo-live-activity";
import {
hasVoicepeakConfiguration,
loadVoicepeakSettings,
requestVoicepeakSpeechBytes,
} from "@/lib/voicepeak";
import { encodeBase64 } from "@/lib/voicepeakAudioSource";
import * as Notifications from "expo-notifications";
export type BackgroundRikkaTriggerSource =
| "deviceLocation"
| "trainPosition";
export const DEFAULT_BACKGROUND_RIKKA_TRIGGER_SOURCE: BackgroundRikkaTriggerSource =
"deviceLocation";
export type BackgroundRikkaStation = {
identifier: string;
stationName: string;
latitude: number;
longitude: number;
};
export type BackgroundRikkaPreparationResult = {
scheduled: number;
failedStations: string[];
};
const MAX_LOCATION_ANNOUNCEMENTS = 20;
const PREPARE_CONCURRENCY = 3;
const DEFAULT_APPROACH_RADIUS_METERS = 800;
const notificationSoundFileName = (stationName: string) => {
let hash = 2166136261;
for (let index = 0; index < stationName.length; index++) {
hash ^= stationName.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return `rikka-next-${(hash >>> 0).toString(36)}.wav`;
};
const ensureNotificationSound = async (
stationName: string,
signal?: AbortSignal
) => {
const settings = await loadVoicepeakSettings();
if (!hasVoicepeakConfiguration(settings)) {
throw new Error("Voicepeak configuration is required");
}
const soundFileName = notificationSoundFileName(stationName);
if (!hasNotificationSound(soundFileName)) {
const audioBytes = await requestVoicepeakSpeechBytes({
text: `次は、${stationName}です。`,
settings,
signal,
format: "wav",
});
await saveNotificationSound(soundFileName, encodeBase64(audioBytes));
}
return soundFileName;
};
export const sendTrainPositionRikkaAnnouncement = async ({
stationName,
trainId,
signal,
}: {
stationName: string;
trainId: string;
signal?: AbortSignal;
}) => {
const soundFileName = await ensureNotificationSound(stationName, signal);
if (signal?.aborted) return;
await Notifications.scheduleNotificationAsync({
content: {
title: "列車追従・りっかちゃん",
body: `次は、${stationName}です。`,
sound: soundFileName,
data: {
type: "train-follow-announcement",
stationName,
trainId,
source: "trainPosition",
},
},
trigger: null,
});
};
export const prepareBackgroundRikkaAnnouncements = async ({
trackingId,
stations,
signal,
}: {
trackingId: string;
stations: BackgroundRikkaStation[];
signal?: AbortSignal;
}): Promise<BackgroundRikkaPreparationResult> => {
const deduplicated = stations
.filter(
(station) =>
station.stationName &&
Number.isFinite(station.latitude) &&
Number.isFinite(station.longitude)
)
.filter(
(station, index, array) =>
array.findIndex((candidate) => candidate.identifier === station.identifier) ===
index
)
.slice(0, MAX_LOCATION_ANNOUNCEMENTS);
const prepared: Array<LocationAnnouncement | null> = new Array(
deduplicated.length
).fill(null);
const failedStations: string[] = [];
let cursor = 0;
const prepareNext = async () => {
while (cursor < deduplicated.length) {
const index = cursor++;
const station = deduplicated[index];
if (signal?.aborted) return;
const soundFileName = notificationSoundFileName(station.stationName);
try {
await ensureNotificationSound(station.stationName, signal);
prepared[index] = {
identifier: station.identifier,
stationName: station.stationName,
latitude: station.latitude,
longitude: station.longitude,
radiusMeters: DEFAULT_APPROACH_RADIUS_METERS,
soundFileName,
};
} catch (error) {
if (!signal?.aborted) {
failedStations.push(station.stationName);
console.warn(
`[BackgroundRikka] Failed to prepare ${station.stationName}`,
error
);
}
}
}
};
await Promise.all(
Array.from(
{ length: Math.min(PREPARE_CONCURRENCY, deduplicated.length) },
prepareNext
)
);
if (signal?.aborted) {
return { scheduled: 0, failedStations };
}
const announcements = prepared.filter(
(announcement): announcement is LocationAnnouncement => announcement != null
);
const scheduled =
announcements.length > 0
? await scheduleLocationAnnouncements(trackingId, announcements)
: 0;
return { scheduled, failedStations };
};
+2 -39
View File
@@ -2,6 +2,8 @@
// Source: https://github.com/micolous/metrodroid (GPL-3.0) // Source: https://github.com/micolous/metrodroid (GPL-3.0)
// Keys: stationId = ((areaCode)<<16) | ((lineCode)<<8) | stationCode // Keys: stationId = ((areaCode)<<16) | ((lineCode)<<8) | stationCode
// areaCode = data[15] >> 6 (from FeliCa history block byte 15) // areaCode = data[15] >> 6 (from FeliCa history block byte 15)
// The source contains historical duplicate IDs; this map intentionally keeps the last
// record for each ID to preserve the original object last-record-wins lookup.
interface FelicaStationEntry { s: string; l: string; c: string } interface FelicaStationEntry { s: string; l: string; c: string }
@@ -61,7 +63,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x000147: { s: '清水', l: '東海道本', c: '東海旅客鉄道' }, 0x000147: { s: '清水', l: '東海道本', c: '東海旅客鉄道' },
0x000149: { s: '草薙', l: '東海道本', c: '東海旅客鉄道' }, 0x000149: { s: '草薙', l: '東海道本', c: '東海旅客鉄道' },
0x00014b: { s: '東静岡', l: '東海道本', c: '東海旅客鉄道' }, 0x00014b: { s: '東静岡', l: '東海道本', c: '東海旅客鉄道' },
0x00014b: { s: '東静岡', l: '東海道本', c: '東海旅客鉄道' },
0x00014c: { s: '静岡', l: '東海道本', c: '東海旅客鉄道' }, 0x00014c: { s: '静岡', l: '東海道本', c: '東海旅客鉄道' },
0x00014e: { s: '安倍川', l: '東海道本', c: '東海旅客鉄道' }, 0x00014e: { s: '安倍川', l: '東海道本', c: '東海旅客鉄道' },
0x00014f: { s: '用宗', l: '東海道本', c: '東海旅客鉄道' }, 0x00014f: { s: '用宗', l: '東海道本', c: '東海旅客鉄道' },
@@ -267,7 +268,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x000271: { s: '太子堂', l: '東北本', c: '東日本旅客鉄道' }, 0x000271: { s: '太子堂', l: '東北本', c: '東日本旅客鉄道' },
0x000272: { s: '長町', l: '東北本', c: '東日本旅客鉄道' }, 0x000272: { s: '長町', l: '東北本', c: '東日本旅客鉄道' },
0x000273: { s: '仙台', l: '東北本', c: '東日本旅客鉄道' }, 0x000273: { s: '仙台', l: '東北本', c: '東日本旅客鉄道' },
0x000273: { s: '仙台', l: '東北本', c: '東日本旅客鉄道' },
0x000275: { s: '東仙台', l: '東北本', c: '東日本旅客鉄道' }, 0x000275: { s: '東仙台', l: '東北本', c: '東日本旅客鉄道' },
0x000276: { s: '岩切', l: '東北本', c: '東日本旅客鉄道' }, 0x000276: { s: '岩切', l: '東北本', c: '東日本旅客鉄道' },
0x000277: { s: '陸前山王', l: '東北本', c: '東日本旅客鉄道' }, 0x000277: { s: '陸前山王', l: '東北本', c: '東日本旅客鉄道' },
@@ -665,9 +665,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x00062c: { s: '大野城', l: '鹿児島本', c: '九州旅客鉄道' }, 0x00062c: { s: '大野城', l: '鹿児島本', c: '九州旅客鉄道' },
0x00062d: { s: '水城', l: '鹿児島本', c: '九州旅客鉄道' }, 0x00062d: { s: '水城', l: '鹿児島本', c: '九州旅客鉄道' },
0x00062e: { s: '都府楼南', l: '鹿児島本', c: '九州旅客鉄道' }, 0x00062e: { s: '都府楼南', l: '鹿児島本', c: '九州旅客鉄道' },
0x00062f: { s: '二日市', l: '鹿児島本', c: '九州旅客鉄道' },
0x00062f: { s: '都府楼南', l: '鹿児島本', c: '九州旅客鉄道' }, 0x00062f: { s: '都府楼南', l: '鹿児島本', c: '九州旅客鉄道' },
0x000630: { s: '二日市', l: '鹿児島本', c: '九州旅客鉄道' },
0x000630: { s: '天拝山', l: '鹿児島本', c: '九州旅客鉄道' }, 0x000630: { s: '天拝山', l: '鹿児島本', c: '九州旅客鉄道' },
0x000631: { s: '天拝山', l: '鹿児島本', c: '九州旅客鉄道' }, 0x000631: { s: '天拝山', l: '鹿児島本', c: '九州旅客鉄道' },
0x000632: { s: '原田', l: '鹿児島本', c: '九州旅客鉄道' }, 0x000632: { s: '原田', l: '鹿児島本', c: '九州旅客鉄道' },
@@ -725,7 +723,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x00071a: { s: '三毛門', l: '日豊本', c: '九州旅客鉄道' }, 0x00071a: { s: '三毛門', l: '日豊本', c: '九州旅客鉄道' },
0x00071b: { s: '吉富', l: '日豊本', c: '九州旅客鉄道' }, 0x00071b: { s: '吉富', l: '日豊本', c: '九州旅客鉄道' },
0x00071c: { s: '中津', l: '日豊本', c: '九州旅客鉄道' }, 0x00071c: { s: '中津', l: '日豊本', c: '九州旅客鉄道' },
0x000728: { s: '中津', l: '日豊本', c: '九州旅客鉄道' },
0x000728: { s: '宇佐', l: '日豊本', c: '九州旅客鉄道' }, 0x000728: { s: '宇佐', l: '日豊本', c: '九州旅客鉄道' },
0x00072a: { s: '西屋敷', l: '日豊本', c: '九州旅客鉄道' }, 0x00072a: { s: '西屋敷', l: '日豊本', c: '九州旅客鉄道' },
0x00073b: { s: '別府', l: '日豊本', c: '九州旅客鉄道' }, 0x00073b: { s: '別府', l: '日豊本', c: '九州旅客鉄道' },
@@ -1113,9 +1110,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x00104c: { s: '高野川', l: '予讃', c: '四国旅客鉄道' }, 0x00104c: { s: '高野川', l: '予讃', c: '四国旅客鉄道' },
0x001058: { s: '五郎', l: '予讃', c: '四国旅客鉄道' }, 0x001058: { s: '五郎', l: '予讃', c: '四国旅客鉄道' },
0x001059: { s: '伊予大洲', l: '予讃', c: '四国旅客鉄道' }, 0x001059: { s: '伊予大洲', l: '予讃', c: '四国旅客鉄道' },
0x001081: { s: '御免', l: 'ごめんなはり', c: '土佐くろしお鉄道' },
0x001081: { s: '後免', l: '阿佐', c: '土佐くろしお鉄道' }, 0x001081: { s: '後免', l: '阿佐', c: '土佐くろしお鉄道' },
0x001083: { s: '後免町', l: '阿佐', c: '土佐くろしお鉄道' },
0x001083: { s: '御免町', l: 'ごめんなはり', c: '土佐くろしお鉄道' }, 0x001083: { s: '御免町', l: 'ごめんなはり', c: '土佐くろしお鉄道' },
0x001102: { s: '金蔵寺', l: '土讃', c: '四国旅客鉄道' }, 0x001102: { s: '金蔵寺', l: '土讃', c: '四国旅客鉄道' },
0x001103: { s: '善通寺', l: '土讃', c: '四国旅客鉄道' }, 0x001103: { s: '善通寺', l: '土讃', c: '四国旅客鉄道' },
@@ -1123,7 +1118,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x00110c: { s: '佃', l: '土讃', c: '四国旅客鉄道' }, 0x00110c: { s: '佃', l: '土讃', c: '四国旅客鉄道' },
0x00110d: { s: '阿波池田', l: '土讃', c: '四国旅客鉄道' }, 0x00110d: { s: '阿波池田', l: '土讃', c: '四国旅客鉄道' },
0x001113: { s: '大歩危', l: '土讃', c: '四国旅客鉄道' }, 0x001113: { s: '大歩危', l: '土讃', c: '四国旅客鉄道' },
0x001124: { s: '御免', l: '土讃', c: '四国旅客鉄道' },
0x001124: { s: '後免', l: '土讃', c: '四国旅客鉄道' }, 0x001124: { s: '後免', l: '土讃', c: '四国旅客鉄道' },
0x00112a: { s: '高知', l: '土讃', c: '四国旅客鉄道' }, 0x00112a: { s: '高知', l: '土讃', c: '四国旅客鉄道' },
0x00112b: { s: '入明', l: '土讃', c: '四国旅客鉄道' }, 0x00112b: { s: '入明', l: '土讃', c: '四国旅客鉄道' },
@@ -1649,7 +1643,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x002984: { s: '岩手和井内', l: '岩泉', c: '東日本旅客鉄道' }, 0x002984: { s: '岩手和井内', l: '岩泉', c: '東日本旅客鉄道' },
0x002986: { s: '押角', l: '岩泉', c: '東日本旅客鉄道' }, 0x002986: { s: '押角', l: '岩泉', c: '東日本旅客鉄道' },
0x002a0c: { s: '新川崎', l: '横須賀', c: '東日本旅客鉄道' }, 0x002a0c: { s: '新川崎', l: '横須賀', c: '東日本旅客鉄道' },
0x002a0d: { s: '武蔵小杉', l: '横須賀', c: '東日本旅客鉄道' },
0x002a0d: { s: '新川崎', l: '横須賀', c: '東日本旅客鉄道' }, 0x002a0d: { s: '新川崎', l: '横須賀', c: '東日本旅客鉄道' },
0x002a0e: { s: '西大井', l: '横須賀', c: '東日本旅客鉄道' }, 0x002a0e: { s: '西大井', l: '横須賀', c: '東日本旅客鉄道' },
0x002a15: { s: '新日本橋', l: '総武本', c: '東日本旅客鉄道' }, 0x002a15: { s: '新日本橋', l: '総武本', c: '東日本旅客鉄道' },
@@ -1671,7 +1664,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x002ada: { s: '登別', l: '室蘭本', c: '北海道旅客鉄道' }, 0x002ada: { s: '登別', l: '室蘭本', c: '北海道旅客鉄道' },
0x002ae0: { s: '白老', l: '室蘭本', c: '北海道旅客鉄道' }, 0x002ae0: { s: '白老', l: '室蘭本', c: '北海道旅客鉄道' },
0x002ae3: { s: '錦岡', l: '室蘭本', c: '北海道旅客鉄道' }, 0x002ae3: { s: '錦岡', l: '室蘭本', c: '北海道旅客鉄道' },
0x002ae3: { s: '錦岡', l: '室蘭本', c: '北海道旅客鉄道' },
0x002ae5: { s: '糸井', l: '室蘭本', c: '北海道旅客鉄道' }, 0x002ae5: { s: '糸井', l: '室蘭本', c: '北海道旅客鉄道' },
0x002ae9: { s: '苫小牧', l: '室蘭本', c: '北海道旅客鉄道' }, 0x002ae9: { s: '苫小牧', l: '室蘭本', c: '北海道旅客鉄道' },
0x002aeb: { s: '沼ノ端', l: '室蘭本', c: '北海道旅客鉄道' }, 0x002aeb: { s: '沼ノ端', l: '室蘭本', c: '北海道旅客鉄道' },
@@ -2281,7 +2273,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x00524a: { s: '三角', l: '三角', c: '九州旅客鉄道' }, 0x00524a: { s: '三角', l: '三角', c: '九州旅客鉄道' },
0x005318: { s: '太田部', l: '小海', c: '東日本旅客鉄道' }, 0x005318: { s: '太田部', l: '小海', c: '東日本旅客鉄道' },
0x005319: { s: '中込', l: '小海', c: '東日本旅客鉄道' }, 0x005319: { s: '中込', l: '小海', c: '東日本旅客鉄道' },
0x00531a: { s: '滑津', l: '小海', c: '東日本旅客鉄道' },
0x00531a: { s: '中込', l: '小海', c: '東日本旅客鉄道' }, 0x00531a: { s: '中込', l: '小海', c: '東日本旅客鉄道' },
0x00531b: { s: '北中込', l: '小海', c: '東日本旅客鉄道' }, 0x00531b: { s: '北中込', l: '小海', c: '東日本旅客鉄道' },
0x00531c: { s: '岩村田', l: '小海', c: '東日本旅客鉄道' }, 0x00531c: { s: '岩村田', l: '小海', c: '東日本旅客鉄道' },
@@ -2291,7 +2282,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x005352: { s: '人吉', l: '肥薩', c: '九州旅客鉄道' }, 0x005352: { s: '人吉', l: '肥薩', c: '九州旅客鉄道' },
0x00540c: { s: '戸狩野沢温泉', l: '飯山', c: '東日本旅客鉄道' }, 0x00540c: { s: '戸狩野沢温泉', l: '飯山', c: '東日本旅客鉄道' },
0x005415: { s: '森宮野原', l: '飯山', c: '東日本旅客鉄道' }, 0x005415: { s: '森宮野原', l: '飯山', c: '東日本旅客鉄道' },
0x005415: { s: '森宮野原', l: '飯山', c: '東日本旅客鉄道' },
0x00541f: { s: '十日町', l: '飯山', c: '東日本旅客鉄道' }, 0x00541f: { s: '十日町', l: '飯山', c: '東日本旅客鉄道' },
0x005420: { s: '魚沼中条', l: '飯山', c: '東日本旅客鉄道' }, 0x005420: { s: '魚沼中条', l: '飯山', c: '東日本旅客鉄道' },
0x005515: { s: '吉田', l: '越後', c: '東日本旅客鉄道' }, 0x005515: { s: '吉田', l: '越後', c: '東日本旅客鉄道' },
@@ -2715,7 +2705,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x007403: { s: '播磨高岡', l: '姫新', c: '西日本旅客鉄道' }, 0x007403: { s: '播磨高岡', l: '姫新', c: '西日本旅客鉄道' },
0x007404: { s: '余部', l: '姫新', c: '西日本旅客鉄道' }, 0x007404: { s: '余部', l: '姫新', c: '西日本旅客鉄道' },
0x007407: { s: '本竜野', l: '姫新', c: '西日本旅客鉄道' }, 0x007407: { s: '本竜野', l: '姫新', c: '西日本旅客鉄道' },
0x007408: { s: '東觜崎', l: '姫新線', c: '西日本旅客鉄道' },
0x007408: { s: '東觜崎', l: '姫新', c: '西日本旅客鉄道' }, 0x007408: { s: '東觜崎', l: '姫新', c: '西日本旅客鉄道' },
0x007409: { s: '播磨新宮', l: '姫新', c: '西日本旅客鉄道' }, 0x007409: { s: '播磨新宮', l: '姫新', c: '西日本旅客鉄道' },
0x007410: { s: '佐用', l: '姫新', c: '西日本旅客鉄道' }, 0x007410: { s: '佐用', l: '姫新', c: '西日本旅客鉄道' },
@@ -3453,7 +3442,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x00c708: { s: '京王多摩センター', l: '相模原', c: '京王電鉄' }, 0x00c708: { s: '京王多摩センター', l: '相模原', c: '京王電鉄' },
0x00c709: { s: '京王堀之内', l: '相模原', c: '京王電鉄' }, 0x00c709: { s: '京王堀之内', l: '相模原', c: '京王電鉄' },
0x00c70a: { s: '南大沢', l: '相模原', c: '京王電鉄' }, 0x00c70a: { s: '南大沢', l: '相模原', c: '京王電鉄' },
0x00c70a: { s: '南大沢', l: '相模原', c: '京王電鉄' },
0x00c70b: { s: '多摩境', l: '相模原', c: '京王電鉄' }, 0x00c70b: { s: '多摩境', l: '相模原', c: '京王電鉄' },
0x00c70c: { s: '橋本', l: '相模原', c: '京王電鉄' }, 0x00c70c: { s: '橋本', l: '相模原', c: '京王電鉄' },
0x00c801: { s: '北野', l: '高尾', c: '京王電鉄' }, 0x00c801: { s: '北野', l: '高尾', c: '京王電鉄' },
@@ -3596,7 +3584,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x00d403: { s: '新高島', l: 'みなとみらい', c: '横浜高速鉄道' }, 0x00d403: { s: '新高島', l: 'みなとみらい', c: '横浜高速鉄道' },
0x00d405: { s: 'みなとみらい', l: 'みなとみらい', c: '横浜高速鉄道' }, 0x00d405: { s: 'みなとみらい', l: 'みなとみらい', c: '横浜高速鉄道' },
0x00d407: { s: '馬車道', l: 'みなとみらい', c: '横浜高速鉄道' }, 0x00d407: { s: '馬車道', l: 'みなとみらい', c: '横浜高速鉄道' },
0x00d409: { s: '日本大通', l: 'みなとみらい', c: '横浜高速鉄道' },
0x00d409: { s: '日本大通り', l: 'みなとみらい', c: '横浜高速鉄道' }, 0x00d409: { s: '日本大通り', l: 'みなとみらい', c: '横浜高速鉄道' },
0x00d40b: { s: '元町・中華街', l: 'みなとみらい', c: '横浜高速鉄道' }, 0x00d40b: { s: '元町・中華街', l: 'みなとみらい', c: '横浜高速鉄道' },
0x00d501: { s: '泉岳寺', l: '本', c: '京浜急行電鉄' }, 0x00d501: { s: '泉岳寺', l: '本', c: '京浜急行電鉄' },
@@ -3654,14 +3641,8 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x00d603: { s: '大鳥居', l: '空港', c: '京浜急行電鉄' }, 0x00d603: { s: '大鳥居', l: '空港', c: '京浜急行電鉄' },
0x00d604: { s: '穴守稲荷', l: '空港', c: '京浜急行電鉄' }, 0x00d604: { s: '穴守稲荷', l: '空港', c: '京浜急行電鉄' },
0x00d605: { s: '天空橋', l: '空港', c: '京浜急行電鉄' }, 0x00d605: { s: '天空橋', l: '空港', c: '京浜急行電鉄' },
0x00d606: { s: '国際ターミナル駅(仮称)', l: '空港', c: '京浜急行電鉄' },
0x00d606: { s: '羽田空港国際線ターミナル', l: '空港', c: '京浜急行電鉄' }, 0x00d606: { s: '羽田空港国際線ターミナル', l: '空港', c: '京浜急行電鉄' },
0x00d607: { s: '羽田空港', l: '空港', c: '京浜急行電鉄' }, 0x00d607: { s: '羽田空港', l: '空港', c: '京浜急行電鉄' },
0x00d607: { s: '羽田空港', l: '空港', c: '京浜急行電鉄' },
0x00d607: { s: '羽田空港', l: '空港', c: '京浜急行電鉄' },
0x00d607: { s: '羽田空港国内線ターミナル', l: '空港', c: '京浜急行電鉄' },
0x00d607: { s: '羽田空港', l: '空港', c: '京浜急行' },
0x00d607: { s: '羽田空港', l: '空港', c: '京浜急行電鉄' },
0x00d701: { s: '京急川崎', l: '大師', c: '京浜急行電鉄' }, 0x00d701: { s: '京急川崎', l: '大師', c: '京浜急行電鉄' },
0x00d702: { s: '港町', l: '大師', c: '京浜急行電鉄' }, 0x00d702: { s: '港町', l: '大師', c: '京浜急行電鉄' },
0x00d703: { s: '鈴木町', l: '大師', c: '京浜急行電鉄' }, 0x00d703: { s: '鈴木町', l: '大師', c: '京浜急行電鉄' },
@@ -4111,7 +4092,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x00fa07: { s: '整備場', l: '東京モノレール羽田', c: '東京モノレール' }, 0x00fa07: { s: '整備場', l: '東京モノレール羽田', c: '東京モノレール' },
0x00fa08: { s: '羽田', l: '東京モノレール羽田', c: '東京モノレール' }, 0x00fa08: { s: '羽田', l: '東京モノレール羽田', c: '東京モノレール' },
0x00fa09: { s: '天空橋', l: '東京モノレール羽田', c: '東京モノレール' }, 0x00fa09: { s: '天空橋', l: '東京モノレール羽田', c: '東京モノレール' },
0x00fa0a: { s: '国際線ターミナルビル', l: '東京モノレール羽田', c: '東京モノレール' },
0x00fa0a: { s: '羽田空港国際線ビル', l: '東京モノレール羽田', c: '東京モノレール' }, 0x00fa0a: { s: '羽田空港国際線ビル', l: '東京モノレール羽田', c: '東京モノレール' },
0x00fa0b: { s: '新整備場', l: '東京モノレール羽田', c: '東京モノレール' }, 0x00fa0b: { s: '新整備場', l: '東京モノレール羽田', c: '東京モノレール' },
0x00fa0c: { s: '羽田空港第1ビル', l: '東京モノレール羽田', c: '東京モノレール' }, 0x00fa0c: { s: '羽田空港第1ビル', l: '東京モノレール羽田', c: '東京モノレール' },
@@ -4823,7 +4803,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x02933e: { s: '元町', l: '本', c: '阪神電気鉄道' }, 0x02933e: { s: '元町', l: '本', c: '阪神電気鉄道' },
0x029401: { s: '大阪難波', l: '阪神なんば', c: '阪神電気鉄道' }, 0x029401: { s: '大阪難波', l: '阪神なんば', c: '阪神電気鉄道' },
0x029403: { s: '桜川', l: '阪神なんば', c: '阪神電気鉄道' }, 0x029403: { s: '桜川', l: '阪神なんば', c: '阪神電気鉄道' },
0x029404: { s: '桜川', l: '阪神なんば', c: '阪神電気鉄道' },
0x029404: { s: 'ドーム前', l: '阪神なんば', c: '阪神電気鉄道' }, 0x029404: { s: 'ドーム前', l: '阪神なんば', c: '阪神電気鉄道' },
0x029405: { s: '九条', l: '阪神なんば', c: '阪神電気鉄道' }, 0x029405: { s: '九条', l: '阪神なんば', c: '阪神電気鉄道' },
0x029407: { s: '西九条', l: '西大阪', c: '阪神電気鉄道' }, 0x029407: { s: '西九条', l: '西大阪', c: '阪神電気鉄道' },
@@ -5122,12 +5101,9 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x02c54e: { s: '鳥羽街道', l: '京阪本', c: '京阪電気鉄道' }, 0x02c54e: { s: '鳥羽街道', l: '京阪本', c: '京阪電気鉄道' },
0x02c550: { s: '東福寺', l: '京阪本', c: '京阪電気鉄道' }, 0x02c550: { s: '東福寺', l: '京阪本', c: '京阪電気鉄道' },
0x02c552: { s: '七条', l: '京阪本', c: '京阪電気鉄道' }, 0x02c552: { s: '七条', l: '京阪本', c: '京阪電気鉄道' },
0x02c554: { s: '五条', l: '京阪本', c: '京阪電気鉄道' },
0x02c554: { s: '清水五条', l: '京阪本', c: '京阪電気鉄道' }, 0x02c554: { s: '清水五条', l: '京阪本', c: '京阪電気鉄道' },
0x02c556: { s: '祇園四条', l: '京阪本', c: '京阪電気鉄道' },
0x02c556: { s: '四条', l: '京阪本', c: '京阪電気鉄道' }, 0x02c556: { s: '四条', l: '京阪本', c: '京阪電気鉄道' },
0x02c558: { s: '三条', l: '京阪本', c: '京阪電気鉄道' }, 0x02c558: { s: '三条', l: '京阪本', c: '京阪電気鉄道' },
0x02c55a: { s: '丸太町', l: '鴨東', c: '京阪電気鉄道' },
0x02c55a: { s: '神宮丸太町', l: '鴨東', c: '京阪電気鉄道' }, 0x02c55a: { s: '神宮丸太町', l: '鴨東', c: '京阪電気鉄道' },
0x02c55c: { s: '出町柳', l: '鴨東', c: '京阪電気鉄道' }, 0x02c55c: { s: '出町柳', l: '鴨東', c: '京阪電気鉄道' },
0x02c5c2: { s: '八幡市', l: '京阪鋼索', c: '京阪電気鉄道' }, 0x02c5c2: { s: '八幡市', l: '京阪鋼索', c: '京阪電気鉄道' },
@@ -5336,12 +5312,8 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x02e603: { s: '近鉄新庄', l: '御所', c: '近畿日本鉄道' }, 0x02e603: { s: '近鉄新庄', l: '御所', c: '近畿日本鉄道' },
0x02e604: { s: '忍海', l: '御所', c: '近畿日本鉄道' }, 0x02e604: { s: '忍海', l: '御所', c: '近畿日本鉄道' },
0x02e605: { s: '近鉄御所', l: '御所', c: '近畿日本鉄道' }, 0x02e605: { s: '近鉄御所', l: '御所', c: '近畿日本鉄道' },
0x02e701: { s: '近鉄難波', l: '難波', c: '近畿日本鉄道' },
0x02e701: { s: '大阪難波', l: '難波', c: '近畿日本鉄道' },
0x02e701: { s: '大阪難波', l: '難波', c: '近畿日本鉄道' }, 0x02e701: { s: '大阪難波', l: '難波', c: '近畿日本鉄道' },
0x02e702: { s: '近鉄日本橋', l: '難波', c: '近畿日本鉄道' }, 0x02e702: { s: '近鉄日本橋', l: '難波', c: '近畿日本鉄道' },
0x02e703: { s: '上本町', l: '大阪', c: '近畿日本鉄道' },
0x02e703: { s: '大阪上本町', l: '大阪', c: '近畿日本鉄道' },
0x02e703: { s: '大阪上本町', l: '大阪', c: '近畿日本鉄道' }, 0x02e703: { s: '大阪上本町', l: '大阪', c: '近畿日本鉄道' },
0x02e704: { s: '鶴橋', l: '大阪', c: '近畿日本鉄道' }, 0x02e704: { s: '鶴橋', l: '大阪', c: '近畿日本鉄道' },
0x02e706: { s: '今里', l: '大阪', c: '近畿日本鉄道' }, 0x02e706: { s: '今里', l: '大阪', c: '近畿日本鉄道' },
@@ -5495,7 +5467,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x02ef16: { s: '桑名', l: '名古屋', c: '近畿日本鉄道' }, 0x02ef16: { s: '桑名', l: '名古屋', c: '近畿日本鉄道' },
0x02ef17: { s: '益生', l: '名古屋', c: '近畿日本鉄道' }, 0x02ef17: { s: '益生', l: '名古屋', c: '近畿日本鉄道' },
0x02ef19: { s: '伊勢朝日', l: '名古屋', c: '近畿日本鉄道' }, 0x02ef19: { s: '伊勢朝日', l: '名古屋', c: '近畿日本鉄道' },
0x02ef1b: { s: '富洲原', l: '名古屋', c: '近畿日本鉄道' },
0x02ef1b: { s: '川越富洲原', l: '名古屋', c: '近畿日本鉄道' }, 0x02ef1b: { s: '川越富洲原', l: '名古屋', c: '近畿日本鉄道' },
0x02ef1d: { s: '近鉄富田', l: '名古屋', c: '近畿日本鉄道' }, 0x02ef1d: { s: '近鉄富田', l: '名古屋', c: '近畿日本鉄道' },
0x02ef1f: { s: '霞ヶ浦', l: '名古屋', c: '近畿日本鉄道' }, 0x02ef1f: { s: '霞ヶ浦', l: '名古屋', c: '近畿日本鉄道' },
@@ -5512,7 +5483,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x02ef2c: { s: '伊勢若松', l: '名古屋', c: '近畿日本鉄道' }, 0x02ef2c: { s: '伊勢若松', l: '名古屋', c: '近畿日本鉄道' },
0x02ef2e: { s: '千代崎', l: '名古屋', c: '近畿日本鉄道' }, 0x02ef2e: { s: '千代崎', l: '名古屋', c: '近畿日本鉄道' },
0x02ef30: { s: '白子', l: '名古屋', c: '近畿日本鉄道' }, 0x02ef30: { s: '白子', l: '名古屋', c: '近畿日本鉄道' },
0x02ef30: { s: '白子', l: '名古屋', c: '近畿日本鉄道' },
0x02ef31: { s: '鼓ヶ浦', l: '名古屋', c: '近畿日本鉄道' }, 0x02ef31: { s: '鼓ヶ浦', l: '名古屋', c: '近畿日本鉄道' },
0x02ef33: { s: '磯山', l: '名古屋', c: '近畿日本鉄道' }, 0x02ef33: { s: '磯山', l: '名古屋', c: '近畿日本鉄道' },
0x02ef35: { s: '千里', l: '名古屋', c: '近畿日本鉄道' }, 0x02ef35: { s: '千里', l: '名古屋', c: '近畿日本鉄道' },
@@ -5573,7 +5543,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x02f303: { s: '馬道 廃止', l: '北勢', c: '近畿日本鉄道' }, 0x02f303: { s: '馬道 廃止', l: '北勢', c: '近畿日本鉄道' },
0x02f304: { s: '西別所 廃止', l: '北勢', c: '近畿日本鉄道' }, 0x02f304: { s: '西別所 廃止', l: '北勢', c: '近畿日本鉄道' },
0x02f305: { s: '蓮花寺 廃止', l: '北勢', c: '近畿日本鉄道' }, 0x02f305: { s: '蓮花寺 廃止', l: '北勢', c: '近畿日本鉄道' },
0x02f305: { s: '蓮花寺 廃止', l: '北勢', c: '近畿日本鉄道' },
0x02f306: { s: '在良 廃止', l: '北勢', c: '近畿日本鉄道' }, 0x02f306: { s: '在良 廃止', l: '北勢', c: '近畿日本鉄道' },
0x02f307: { s: '坂井橋 廃止', l: '北勢', c: '近畿日本鉄道' }, 0x02f307: { s: '坂井橋 廃止', l: '北勢', c: '近畿日本鉄道' },
0x02f30b: { s: '六把野 廃止', l: '北勢', c: '近畿日本鉄道' }, 0x02f30b: { s: '六把野 廃止', l: '北勢', c: '近畿日本鉄道' },
@@ -5629,15 +5598,12 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x02ffb7: { s: '貿易センター', l: 'ポートアイランド', c: '神戸新交通' }, 0x02ffb7: { s: '貿易センター', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffb9: { s: 'ポートターミナル', l: 'ポートアイランド', c: '神戸新交通' }, 0x02ffb9: { s: 'ポートターミナル', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffbb: { s: '中公園', l: 'ポートアイランド', c: '神戸新交通' }, 0x02ffbb: { s: '中公園', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffbd: { s: '市民病院前', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffbd: { s: 'みなとじま', l: 'ポートアイランド', c: '神戸新交通' }, 0x02ffbd: { s: 'みなとじま', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffbf: { s: '市民広場', l: 'ポートアイランド', c: '神戸新交通' }, 0x02ffbf: { s: '市民広場', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffc1: { s: '南公園', l: 'ポートアイランド', c: '神戸新交通' }, 0x02ffc1: { s: '南公園', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffc3: { s: '中埠頭', l: 'ポートアイランド', c: '神戸新交通' }, 0x02ffc3: { s: '中埠頭', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffc5: { s: '北埠頭', l: 'ポートアイランド', c: '神戸新交通' }, 0x02ffc5: { s: '北埠頭', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffc7: { s: '先端医療センター前', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffc7: { s: '医療センター', l: 'ポートアイランド', c: '神戸新交通' }, 0x02ffc7: { s: '医療センター', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffc9: { s: '京コンピュータ前', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffc9: { s: 'ポートアイランド南', l: 'ポートアイランド', c: '神戸新交通' }, 0x02ffc9: { s: 'ポートアイランド南', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffcb: { s: '神戸空港', l: 'ポートアイランド', c: '神戸新交通' }, 0x02ffcb: { s: '神戸空港', l: 'ポートアイランド', c: '神戸新交通' },
0x032841: { s: '川西', l: '錦川清流', c: '錦川鉄道' }, 0x032841: { s: '川西', l: '錦川清流', c: '錦川鉄道' },
@@ -5722,7 +5688,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x03be19: { s: '城北', l: 'アストラムライン', c: '広島高速交通' }, 0x03be19: { s: '城北', l: 'アストラムライン', c: '広島高速交通' },
0x03be1d: { s: '白島', l: 'アストラムライン', c: '広島高速交通' }, 0x03be1d: { s: '白島', l: 'アストラムライン', c: '広島高速交通' },
0x03be1f: { s: '牛田', l: 'アストラムライン', c: '広島高速交通' }, 0x03be1f: { s: '牛田', l: 'アストラムライン', c: '広島高速交通' },
0x03be21: { s: '不動院前', l: 'アストラムライン', c: '広島' },
0x03be21: { s: '不動院前', l: 'アストラムライン', c: '広島高速交通' }, 0x03be21: { s: '不動院前', l: 'アストラムライン', c: '広島高速交通' },
0x03be23: { s: '祇園新橋北', l: 'アストラムライン', c: '広島高速交通' }, 0x03be23: { s: '祇園新橋北', l: 'アストラムライン', c: '広島高速交通' },
0x03be25: { s: '西原', l: 'アストラムライン', c: '広島高速交通' }, 0x03be25: { s: '西原', l: 'アストラムライン', c: '広島高速交通' },
@@ -5832,7 +5797,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x03d64d: { s: '西鉄千早', l: '貝塚', c: '西日本鉄道' }, 0x03d64d: { s: '西鉄千早', l: '貝塚', c: '西日本鉄道' },
0x03d64f: { s: '名島', l: '貝塚', c: '西日本鉄道' }, 0x03d64f: { s: '名島', l: '貝塚', c: '西日本鉄道' },
0x03d651: { s: '貝塚', l: '貝塚', c: '西日本鉄道' }, 0x03d651: { s: '貝塚', l: '貝塚', c: '西日本鉄道' },
0x03d765: { s: '西鉄福岡(天神)', l: '天神大牟田線', c: '西日本鉄道' },
0x03d765: { s: '西鉄福岡(天神)', l: '天神大牟田', c: '西日本鉄道' }, 0x03d765: { s: '西鉄福岡(天神)', l: '天神大牟田', c: '西日本鉄道' },
0x03d767: { s: '薬院', l: '天神大牟田', c: '西日本鉄道' }, 0x03d767: { s: '薬院', l: '天神大牟田', c: '西日本鉄道' },
0x03d769: { s: '西鉄平尾', l: '天神大牟田', c: '西日本鉄道' }, 0x03d769: { s: '西鉄平尾', l: '天神大牟田', c: '西日本鉄道' },
@@ -5846,7 +5810,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x03d777: { s: '下大利', l: '天神大牟田', c: '西日本鉄道' }, 0x03d777: { s: '下大利', l: '天神大牟田', c: '西日本鉄道' },
0x03d779: { s: '都府楼前', l: '天神大牟田', c: '西日本鉄道' }, 0x03d779: { s: '都府楼前', l: '天神大牟田', c: '西日本鉄道' },
0x03d77b: { s: '西鉄二日市', l: '天神大牟田', c: '西日本鉄道' }, 0x03d77b: { s: '西鉄二日市', l: '天神大牟田', c: '西日本鉄道' },
0x03d77c: { s: '二日市南', l: '天神大牟田', c: '西日本鉄道' },
0x03d77c: { s: '紫', l: '天神大牟田', c: '西日本鉄道' }, 0x03d77c: { s: '紫', l: '天神大牟田', c: '西日本鉄道' },
0x03d77d: { s: '朝倉街道', l: '天神大牟田', c: '西日本鉄道' }, 0x03d77d: { s: '朝倉街道', l: '天神大牟田', c: '西日本鉄道' },
0x03d77f: { s: '桜台', l: '天神大牟田', c: '西日本鉄道' }, 0x03d77f: { s: '桜台', l: '天神大牟田', c: '西日本鉄道' },
+14 -49
View File
@@ -1,53 +1,18 @@
import { trainTypeID } from "@/lib/CommonTypes"; import { trainTypeID } from "@/lib/CommonTypes";
import {
getTrainType,
type TrainTypeFontFamily,
} from "@/lib/getTrainType";
type types = (
type: trainTypeID,
id: string,
) => [string, TrainTypeFontFamily | undefined, boolean];
type types = (types: trainTypeID, id: string) => [string, boolean, boolean];
export const getStringConfig: types = (type, id) => { export const getStringConfig: types = (type, id) => {
switch (type) { const { shortName, fontFamily, isOneMan } = getTrainType({ type, id });
case "Normal": const typeString =
return ["普通", true, false]; type === "Other" && shortName === "その他" ? "" : shortName;
case "OneMan":
return ["普通", true, true]; return [typeString, fontFamily, isOneMan];
case "Rapid":
return ["快速", true, false];
case "OneManRapid":
return ["快速", true, true];
case "LTDEXP":
return ["特急", true, false];
case "NightLTDEXP":
return ["特急", true, false];
case "SPCL":
return ["臨時", true, false];
case "SPCL_Normal":
return ["臨時", true, false];
case "SPCL_Rapid":
return ["臨時快速", true, false];
case "SPCL_EXP":
return ["臨時特急", true, false];
case "Party":
return ["団体", true, false];
case "Freight":
return ["貨物", false, false];
case "Forwarding":
return ["回送", false, false];
case "Trial":
return ["試運転", false, false];
case "Construction":
return ["工事", false, false];
case "FreightForwarding":
return ["単機回送", false, false];
case "Other":
switch (true) {
case !!id.includes("T"):
return ["単機回送", false, false];
case !!id.includes("R"):
case !!id.includes("E"):
case !!id.includes("L"):
case !!id.includes("A"):
case !!id.includes("B"):
return ["回送", false, false];
case !!id.includes("H"):
return ["試運転", false, false];
}
return ["", false, false];
}
}; };
+21 -20
View File
@@ -29,6 +29,7 @@ type trainTypeString =
| "試運転" | "試運転"
| "工事" | "工事"
| "その他"; | "その他";
export type TrainTypeFontFamily = "JR-Nishi" | "JR-WEST-PLUS";
type trainTypeDataString = "rapid" | "express" | "normal" | "notService"; type trainTypeDataString = "rapid" | "express" | "normal" | "notService";
type getTrainType = (e: { type getTrainType = (e: {
type: trainTypeID; type: trainTypeID;
@@ -38,7 +39,7 @@ type getTrainType = (e: {
color: colorString; color: colorString;
name: trainTypeString; name: trainTypeString;
shortName: string; shortName: string;
fontAvailable: boolean; fontFamily: TrainTypeFontFamily | undefined;
isOneMan: boolean; isOneMan: boolean;
data: trainTypeDataString; data: trainTypeDataString;
}; };
@@ -49,7 +50,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: whiteMode ? "#333333ff" : "white", color: whiteMode ? "#333333ff" : "white",
name: "普通列車", name: "普通列車",
shortName: "普通", shortName: "普通",
fontAvailable: true, fontFamily: "JR-Nishi",
isOneMan: false, isOneMan: false,
data: "normal", data: "normal",
}; };
@@ -58,7 +59,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: whiteMode ? "#333333ff" : "white", color: whiteMode ? "#333333ff" : "white",
name: "普通列車(ワンマン)", name: "普通列車(ワンマン)",
shortName: "普通", shortName: "普通",
fontAvailable: true, fontFamily: "JR-Nishi",
isOneMan: true, isOneMan: true,
data: "normal", data: "normal",
}; };
@@ -67,7 +68,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: whiteMode ? "#00a0bdff" : "aqua", color: whiteMode ? "#00a0bdff" : "aqua",
name: "快速", name: "快速",
shortName: "快速", shortName: "快速",
fontAvailable: true, fontFamily: "JR-Nishi",
isOneMan: false, isOneMan: false,
data: "rapid", data: "rapid",
}; };
@@ -76,7 +77,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: whiteMode ? "#00a0bdff" : "aqua", color: whiteMode ? "#00a0bdff" : "aqua",
name: "快速", name: "快速",
shortName: "快速", shortName: "快速",
fontAvailable: true, fontFamily: "JR-Nishi",
isOneMan: true, isOneMan: true,
data: "rapid", data: "rapid",
}; };
@@ -85,7 +86,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#ee424dff", color: "#ee424dff",
name: "特急", name: "特急",
shortName: "特急", shortName: "特急",
fontAvailable: true, fontFamily: "JR-Nishi",
isOneMan: false, isOneMan: false,
data: "express", data: "express",
}; };
@@ -94,7 +95,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: whiteMode ? "#e000b0ff" : "pink", color: whiteMode ? "#e000b0ff" : "pink",
name: "寝台特急", name: "寝台特急",
shortName: "特急", shortName: "特急",
fontAvailable: true, fontFamily: "JR-Nishi",
isOneMan: false, isOneMan: false,
data: "express", data: "express",
}; };
@@ -104,7 +105,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#297bff", color: "#297bff",
name: "臨時", name: "臨時",
shortName: "臨時", shortName: "臨時",
fontAvailable: true, fontFamily: "JR-Nishi",
isOneMan: false, isOneMan: false,
data: "normal", data: "normal",
}; };
@@ -113,7 +114,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#297bff", color: "#297bff",
name: "臨時快速", name: "臨時快速",
shortName: "臨時快速", shortName: "臨時快速",
fontAvailable: true, fontFamily: "JR-Nishi",
isOneMan: false, isOneMan: false,
data: "normal", data: "normal",
}; };
@@ -122,7 +123,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#297bff", color: "#297bff",
name: "臨時特急", name: "臨時特急",
shortName: "臨時特急", shortName: "臨時特急",
fontAvailable: true, fontFamily: "JR-Nishi",
isOneMan: false, isOneMan: false,
data: "normal", data: "normal",
}; };
@@ -131,7 +132,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#ff7300ff", color: "#ff7300ff",
name: "団体", name: "団体",
shortName: "団体", shortName: "団体",
fontAvailable: true, fontFamily: "JR-Nishi",
isOneMan: false, isOneMan: false,
data: "normal", data: "normal",
}; };
@@ -140,7 +141,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#007488ff", color: "#007488ff",
name: "貨物", name: "貨物",
shortName: "貨物", shortName: "貨物",
fontAvailable: false, fontFamily: undefined,
isOneMan: false, isOneMan: false,
data: "notService", data: "notService",
}; };
@@ -149,7 +150,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#5f5f5fff", color: "#5f5f5fff",
name: "回送", name: "回送",
shortName: "回送", shortName: "回送",
fontAvailable: false, fontFamily: "JR-WEST-PLUS",
isOneMan: false, isOneMan: false,
data: "notService", data: "notService",
}; };
@@ -158,7 +159,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#5f5f5fff", color: "#5f5f5fff",
name: "試運転", name: "試運転",
shortName: "試運転", shortName: "試運転",
fontAvailable: false, fontFamily: "JR-WEST-PLUS",
isOneMan: false, isOneMan: false,
data: "notService", data: "notService",
}; };
@@ -167,7 +168,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#5f5f5fff", color: "#5f5f5fff",
name: "工事", name: "工事",
shortName: "工事", shortName: "工事",
fontAvailable: false, fontFamily: undefined,
isOneMan: false, isOneMan: false,
data: "notService", data: "notService",
}; };
@@ -176,7 +177,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#5f5f5fff", color: "#5f5f5fff",
name: "単機回送", name: "単機回送",
shortName: "単機回送", shortName: "単機回送",
fontAvailable: false, fontFamily: undefined,
isOneMan: false, isOneMan: false,
data: "notService", data: "notService",
}; };
@@ -189,7 +190,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#5f5f5fff", color: "#5f5f5fff",
name: "単機回送", name: "単機回送",
shortName: "単機回送", shortName: "単機回送",
fontAvailable: false, fontFamily: undefined,
isOneMan: false, isOneMan: false,
data: "notService", data: "notService",
}; };
@@ -202,7 +203,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#5f5f5fff", color: "#5f5f5fff",
name: "回送", name: "回送",
shortName: "回送", shortName: "回送",
fontAvailable: false, fontFamily: "JR-WEST-PLUS",
isOneMan: false, isOneMan: false,
data: "notService", data: "notService",
}; };
@@ -211,7 +212,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#5f5f5fff", color: "#5f5f5fff",
name: "試運転", name: "試運転",
shortName: "試運転", shortName: "試運転",
fontAvailable: false, fontFamily: "JR-WEST-PLUS",
isOneMan: false, isOneMan: false,
data: "notService", data: "notService",
}; };
@@ -221,7 +222,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: whiteMode ? "#333333ff" : "white", color: whiteMode ? "#333333ff" : "white",
name: "その他", name: "その他",
shortName: "その他", shortName: "その他",
fontAvailable: false, fontFamily: undefined,
isOneMan: false, isOneMan: false,
data: "normal", data: "normal",
}; };
+30
View File
@@ -0,0 +1,30 @@
import { STORAGE_KEYS } from "@/constants";
import { AS } from "@/storageControl";
const LEGACY_VOICEPEAK_STORAGE_KEYS = [
STORAGE_KEYS.VOICEPEAK_BASE_URL,
STORAGE_KEYS.VOICEPEAK_API_TOKEN,
STORAGE_KEYS.VOICEPEAK_SPEAKER,
"voicepeakSpeed",
"voicepeakPitch",
"voicepeakPause",
"voicepeakVolume",
"voicepeakHightension",
"voicepeakNarration",
"voicepeakParameters",
"voicepeakParams",
"voicepeakEmotions",
"voicepeakEmotion",
] as const;
/**
* APIへの移行後はAPIの設定を保持しない
* removeItemは対象が存在しない場合も安全なため
*/
export const migrateLegacyVoicepeakSettings = async () => {
await Promise.all(
LEGACY_VOICEPEAK_STORAGE_KEYS.map((key) =>
AS.removeItem(key).catch(() => undefined)
)
);
};
+46 -6
View File
@@ -1,3 +1,6 @@
import * as Sentry from "@sentry/react-native";
import { observedFetchJson } from "@/lib/observability/network/observedFetch";
/** /**
* Position Masters JR Shikoku mock API * Position Masters JR Shikoku mock API
* *
@@ -32,6 +35,8 @@ export type PositionLookup = Map<string, string>;
/** Module-level cache (lives for the app session, not persisted). */ /** Module-level cache (lives for the app session, not persisted). */
let _cache: PositionMaster[] | null = null; let _cache: PositionMaster[] | null = null;
let _lastGoodTrainPositions: any[] | null = null;
let _lastGoodTrainPositionsAt: number | null = null;
/** /**
* Fetch position masters from the remote API. * Fetch position masters from the remote API.
@@ -39,9 +44,16 @@ let _cache: PositionMaster[] | null = null;
*/ */
export const fetchPositionMasters = async (): Promise<PositionMaster[]> => { export const fetchPositionMasters = async (): Promise<PositionMaster[]> => {
if (_cache) return _cache; if (_cache) return _cache;
const res = await fetch(POSITION_MASTERS_URL); const data = await observedFetchJson<PositionMaster[]>(POSITION_MASTERS_URL, {
if (!res.ok) throw new Error(`position-masters fetch failed: ${res.status}`); endpoint: "positions_master",
const data: PositionMaster[] = await res.json(); source: "mock_api",
userVisible: false,
preload: true,
fetchPriority: "medium",
timeoutMs: 8000,
retry: true,
urlPathTemplate: "/position-masters",
});
_cache = data; _cache = data;
return data; return data;
}; };
@@ -95,7 +107,35 @@ export const serializePosLookupForJs = (lookup: PositionLookup): string => {
* Throws on network error or non-OK response. * Throws on network error or non-OK response.
*/ */
export const fetchMockTrainPositions = async (): Promise<any[]> => { export const fetchMockTrainPositions = async (): Promise<any[]> => {
const res = await fetch(MOCK_TRAIN_POSITIONS_URL); try {
if (!res.ok) throw new Error(`mock train-positions fetch failed: ${res.status}`); const data = await observedFetchJson<any[]>(MOCK_TRAIN_POSITIONS_URL, {
return res.json(); endpoint: "positions_current",
source: "mock_api",
userVisible: true,
preload: false,
fetchPriority: "high",
timeoutMs: 8000,
retry: true,
urlPathTemplate: "/train-positions/current",
});
_lastGoodTrainPositions = data;
_lastGoodTrainPositionsAt = Date.now();
return data;
} catch (error) {
if (_lastGoodTrainPositions) {
Sentry.addBreadcrumb({
category: "data_fetch",
level: "warning",
message: "fetch:stale_cache",
data: {
endpoint: "positions_current",
source: "mock_api",
stale: true,
cacheAgeSeconds: _lastGoodTrainPositionsAt ? Math.round((Date.now() - _lastGoodTrainPositionsAt) / 1000) : undefined,
},
});
return _lastGoodTrainPositions;
}
throw error;
}
}; };
+291 -14
View File
@@ -1,3 +1,6 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Directory, File, Paths } from 'expo-file-system';
import { AS } from '../../storageControl'; import { AS } from '../../storageControl';
import { STORAGE_KEYS } from '../../constants/storage'; import { STORAGE_KEYS } from '../../constants/storage';
import { TrainEntry } from './webviewXhrInterceptor'; import { TrainEntry } from './webviewXhrInterceptor';
@@ -24,6 +27,60 @@ export type TrainRecording = {
snapshots: TrainSnapshot[]; snapshots: TrainSnapshot[];
}; };
export type RecordingExportEnvelope = {
format: 'jrshikoku-train-recording';
version: 1;
exportedAt: string;
recording: TrainRecording;
};
export type RecordingsExportEnvelope = {
format: 'jrshikoku-train-recordings';
version: 1;
exportedAt: string;
recordings: TrainRecording[];
};
export type RecordingImportResult = {
importedCount: number;
overwrittenCount: number;
};
const RECORDING_EXPORT_FORMAT = 'jrshikoku-train-recording' as const;
const RECORDINGS_EXPORT_FORMAT = 'jrshikoku-train-recordings' as const;
const RECORDING_FILE_DIRECTORY = 'train-recordings';
const getRecordingsDirectory = () => new Directory(Paths.document, RECORDING_FILE_DIRECTORY);
const ensureRecordingsDirectory = () => {
const directory = getRecordingsDirectory();
directory.create({ idempotent: true, intermediates: true });
return directory;
};
const getRecordingFileName = (id: string) => `${encodeURIComponent(id)}.json`;
const getRecordingFile = (id: string, createDirectory = false) =>
new File(createDirectory ? ensureRecordingsDirectory() : getRecordingsDirectory(), getRecordingFileName(id));
const getLegacyRecordingStorageKey = (id: string) =>
(STORAGE_KEYS.MOCK_RECORDING_DATA_PREFIX + id) as string;
const removeLegacyRecordingStorage = async (id: string) => {
const key = getLegacyRecordingStorageKey(id);
await AS.removeItem(key).catch(() => {});
};
const cleanupLegacyRecordingStorage = async () => {
const prefix = STORAGE_KEYS.MOCK_RECORDING_DATA_PREFIX;
const keys = await AsyncStorage.getAllKeys().catch(() => []);
await Promise.all(
keys
.filter((key) => key.startsWith(prefix))
.map((key) => AsyncStorage.removeItem(key).catch(() => {})),
);
};
const parse = <T>(raw: string | null | boolean): T | null => { const parse = <T>(raw: string | null | boolean): T | null => {
if (!raw) return null; if (!raw) return null;
try { try {
@@ -37,6 +94,99 @@ const parse = <T>(raw: string | null | boolean): T | null => {
export const generateRecordingId = (recordedAt: string) => export const generateRecordingId = (recordedAt: string) =>
recordedAt.replace(/[:.]/g, '-'); recordedAt.replace(/[:.]/g, '-');
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const normalizeDirection = (value: unknown): 0 | 1 => (value === 1 ? 1 : 0);
const normalizeDelay = (value: unknown): number | string =>
typeof value === 'number' || typeof value === 'string' ? value : 0;
const normalizeTrainEntry = (value: unknown): TrainEntry | null => {
if (!isPlainObject(value)) return null;
if (typeof value.TrainNum !== 'string') return null;
if (typeof value.Line !== 'string') return null;
if (typeof value.Type !== 'string') return null;
return {
Index: typeof value.Index === 'number' ? value.Index : 0,
TrainNum: value.TrainNum,
Pos: typeof value.Pos === 'string' ? value.Pos : '',
PosNum: typeof value.PosNum === 'number' ? value.PosNum : 0,
delay: normalizeDelay(value.delay),
Direction: normalizeDirection(value.Direction),
Type: value.Type,
Line: value.Line,
};
};
const normalizeSnapshot = (value: unknown): TrainSnapshot | null => {
if (!isPlainObject(value)) return null;
if (!Array.isArray(value.trains)) return null;
const trains = value.trains
.map(normalizeTrainEntry)
.filter((entry): entry is TrainEntry => entry !== null);
if (trains.length !== value.trains.length) return null;
return {
t: typeof value.t === 'number' && Number.isFinite(value.t) ? Math.max(0, value.t) : 0,
trains,
};
};
const normalizeRecording = (value: unknown): TrainRecording | null => {
if (!isPlainObject(value)) return null;
if (typeof value.recordedAt !== 'string') return null;
if (!Array.isArray(value.snapshots)) return null;
const snapshots = value.snapshots
.map(normalizeSnapshot)
.filter((snapshot): snapshot is TrainSnapshot => snapshot !== null);
if (snapshots.length !== value.snapshots.length) return null;
const durationMs =
typeof value.durationMs === 'number' && Number.isFinite(value.durationMs)
? Math.max(0, value.durationMs)
: snapshots[snapshots.length - 1]?.t ?? 0;
const id =
typeof value.id === 'string' && value.id.trim().length > 0
? value.id
: generateRecordingId(value.recordedAt);
return {
id,
recordedAt: value.recordedAt,
durationMs,
snapshots,
};
};
const extractRecordingsFromImport = (value: unknown): TrainRecording[] => {
if (isPlainObject(value) && value.format === RECORDING_EXPORT_FORMAT) {
const recording = normalizeRecording(value.recording);
return recording ? [recording] : [];
}
if (isPlainObject(value) && value.format === RECORDINGS_EXPORT_FORMAT && Array.isArray(value.recordings)) {
return value.recordings
.map(normalizeRecording)
.filter((recording): recording is TrainRecording => recording !== null);
}
if (Array.isArray(value)) {
return value
.map(normalizeRecording)
.filter((recording): recording is TrainRecording => recording !== null);
}
const recording = normalizeRecording(value);
return recording ? [recording] : [];
};
/** 録画インデックス(メタ一覧)を読み込む */ /** 録画インデックス(メタ一覧)を読み込む */
export const loadRecordingList = async (): Promise<RecordingMeta[]> => { export const loadRecordingList = async (): Promise<RecordingMeta[]> => {
const raw = await AS.getItem(STORAGE_KEYS.MOCK_RECORDINGS_INDEX).catch(() => null); const raw = await AS.getItem(STORAGE_KEYS.MOCK_RECORDINGS_INDEX).catch(() => null);
@@ -45,16 +195,57 @@ export const loadRecordingList = async (): Promise<RecordingMeta[]> => {
/** フル録画データをIDで読み込む */ /** フル録画データをIDで読み込む */
export const loadRecordingById = async (id: string): Promise<TrainRecording | null> => { 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); const file = getRecordingFile(id);
return parse<TrainRecording>(raw); if (file.exists) {
const raw = await file.text().catch(() => null);
const recording = parse<TrainRecording>(raw);
if (recording) return recording;
}
const legacyKey = getLegacyRecordingStorageKey(id);
const legacyRaw = await AS.getItem(legacyKey).catch(() => null);
const legacyRecording = parse<TrainRecording>(legacyRaw);
if (!legacyRecording) return null;
await writeRecordingFile(legacyRecording);
await removeLegacyRecordingStorage(id);
return legacyRecording;
};
/** 全録画を新しい順で読み込む */
export const loadAllRecordings = async (): Promise<TrainRecording[]> => {
const list = await loadRecordingList();
const recordings = await Promise.all(list.map((meta) => loadRecordingById(meta.id)));
return recordings.filter((recording): recording is TrainRecording => recording !== null);
};
const migrateIndexedLegacyRecordings = async (): Promise<boolean> => {
const list = await loadRecordingList();
if (list.length === 0) return true;
const results = await Promise.all(
list.map(async (meta) => {
const recording = await loadRecordingById(meta.id).catch(() => null);
return recording !== null;
}),
);
return results.every(Boolean);
};
const writeRecordingFile = async (recording: TrainRecording): Promise<void> => {
const file = getRecordingFile(recording.id, true);
if (file.exists) {
file.delete();
}
file.create({ overwrite: true });
file.write(JSON.stringify(recording));
}; };
/** 録画を保存してインデックスに追加する */ /** 録画を保存してインデックスに追加する */
export const saveRecording = async (recording: TrainRecording): Promise<void> => { export const saveRecording = async (recording: TrainRecording): Promise<void> => {
await AS.setItem( await writeRecordingFile(recording);
(STORAGE_KEYS.MOCK_RECORDING_DATA_PREFIX + recording.id) as any, await removeLegacyRecordingStorage(recording.id);
JSON.stringify(recording),
);
const list = await loadRecordingList(); const list = await loadRecordingList();
const meta: RecordingMeta = { const meta: RecordingMeta = {
id: recording.id, id: recording.id,
@@ -69,7 +260,11 @@ export const saveRecording = async (recording: TrainRecording): Promise<void> =>
/** 録画をIDで削除する */ /** 録画をIDで削除する */
export const deleteRecordingById = async (id: string): Promise<void> => { export const deleteRecordingById = async (id: string): Promise<void> => {
await AS.removeItem((STORAGE_KEYS.MOCK_RECORDING_DATA_PREFIX + id) as any).catch(() => {}); const file = getRecordingFile(id);
if (file.exists) {
file.delete();
}
await removeLegacyRecordingStorage(id);
const list = await loadRecordingList(); const list = await loadRecordingList();
await AS.setItem( await AS.setItem(
STORAGE_KEYS.MOCK_RECORDINGS_INDEX, STORAGE_KEYS.MOCK_RECORDINGS_INDEX,
@@ -78,17 +273,99 @@ export const deleteRecordingById = async (id: string): Promise<void> => {
}; };
/** /**
* MOCK_RECORDING * MOCK_RECORDING/SQLite保存
*
*/ */
export const migrateOldRecording = async (): Promise<void> => { export const migrateOldRecording = async (): Promise<void> => {
const indexedRecordingsMigrated = await migrateIndexedLegacyRecordings();
const list = await loadRecordingList(); const list = await loadRecordingList();
if (list.length > 0) return; // 既に移行済み if (list.length > 0) {
if (indexedRecordingsMigrated) {
await cleanupLegacyRecordingStorage();
}
return;
}
const raw = await AS.getItem(STORAGE_KEYS.MOCK_RECORDING).catch(() => null); const raw = await AS.getItem(STORAGE_KEYS.MOCK_RECORDING).catch(() => null);
const old = parse<Omit<TrainRecording, 'id'>>(raw); const old = parse<Omit<TrainRecording, 'id'>>(raw);
if (!old) return; if (old) {
const id = generateRecordingId(old.recordedAt); const id = generateRecordingId(old.recordedAt);
await saveRecording({ ...old, id }); await saveRecording({ ...old, id });
await AS.removeItem(STORAGE_KEYS.MOCK_RECORDING).catch(() => {}); await AS.removeItem(STORAGE_KEYS.MOCK_RECORDING).catch(() => {});
}
await cleanupLegacyRecordingStorage();
}; };
/** 録画1件をファイル/クリップボード用JSON文字列へ変換する */
export const buildRecordingExportText = async (id: string): Promise<string> => {
const recording = await loadRecordingById(id);
if (!recording) {
throw new Error('録画データが見つかりませんでした。');
}
const payload: RecordingExportEnvelope = {
format: RECORDING_EXPORT_FORMAT,
version: 1,
exportedAt: new Date().toISOString(),
recording,
};
return JSON.stringify(payload, null, 2);
};
/** 全録画をファイル/クリップボード用JSON文字列へ変換する */
export const buildAllRecordingsExportText = async (): Promise<string> => {
const recordings = await loadAllRecordings();
if (recordings.length === 0) {
throw new Error('書き出せる録画がありません。');
}
const payload: RecordingsExportEnvelope = {
format: RECORDINGS_EXPORT_FORMAT,
version: 1,
exportedAt: new Date().toISOString(),
recordings,
};
return JSON.stringify(payload, null, 2);
};
/** コピペ/ファイルから読み込んだJSON文字列を録画として保存する */
export const importRecordingsFromText = async (text: string): Promise<RecordingImportResult> => {
const trimmed = text.trim();
if (!trimmed) {
throw new Error('インポートするJSONが空です。');
}
let parsedValue: unknown;
try {
parsedValue = JSON.parse(trimmed);
} catch {
throw new Error('JSONとして読み取れませんでした。ファイル内容を確認してください。');
}
const recordings = extractRecordingsFromImport(parsedValue);
if (recordings.length === 0) {
throw new Error('有効な録画データが見つかりませんでした。');
}
if (await migrateIndexedLegacyRecordings()) {
await cleanupLegacyRecordingStorage();
}
const existingIds = new Set((await loadRecordingList()).map((meta) => meta.id));
let overwrittenCount = 0;
for (const recording of recordings) {
if (existingIds.has(recording.id)) {
overwrittenCount += 1;
}
await saveRecording(recording);
existingIds.add(recording.id);
}
return {
importedCount: recordings.length,
overwrittenCount,
};
};
@@ -0,0 +1,234 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { AppState, AppStateStatus, Platform } from "react-native";
import * as Sentry from "@sentry/react-native";
import * as Updates from "expo-updates";
import { lastObservedRootRouteRef } from "@/lib/rootNavigation";
const STORAGE_KEY = "@jrshikoku/app_crash_sentinel_v1";
const HEARTBEAT_INTERVAL_MS = 7000;
const RECENT_HEARTBEAT_MS = 120000;
const MIN_SESSION_AGE_MS = 5000;
type RootNavigationSnapshot = {
rootRoute: string | null;
nestedRoute?: string | null;
rootIndex?: number;
nestedIndex?: number;
source?: string;
updatedAt: string;
};
type CrashSentinelState = {
app_session_active: boolean;
normal_background: boolean;
sessionStartedAt: string;
lastHeartbeatAt: string;
lastKnownAppState: AppStateStatus | "unknown";
lastRootTab: string | null;
previousRootNavigation: RootNavigationSnapshot | null;
activeWebViews: string[];
nativeScreensMode: string;
reportedUnexpectedExitForSessionStartedAt?: string;
};
type StartOptions = {
nativeScreensMode: string;
};
let currentState: CrashSentinelState | null = null;
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
let appStateSubscription: { remove: () => void } | null = null;
let latestNavigationSnapshot: RootNavigationSnapshot | null = null;
const activeWebViews = new Set<string>();
const nowIso = () => new Date().toISOString();
const safeJsonParse = <T>(value: string | null): T | null => {
if (!value) return null;
try {
return JSON.parse(value) as T;
} catch {
return null;
}
};
const getExpoUpdateContext = () => ({
update_id: (Updates as any).updateId ?? null,
updateId: (Updates as any).updateId ?? null,
channel: (Updates as any).channel ?? null,
runtimeVersion: (Updates as any).runtimeVersion ?? null,
createdAt: (Updates as any).createdAt ?? null,
isEmbeddedLaunch: (Updates as any).isEmbeddedLaunch ?? null,
});
const getMemoryInfo = () => {
const performanceMemory = (globalThis as any)?.performance?.memory;
if (!performanceMemory) return null;
return {
jsHeapSizeLimit: performanceMemory.jsHeapSizeLimit ?? null,
totalJSHeapSize: performanceMemory.totalJSHeapSize ?? null,
usedJSHeapSize: performanceMemory.usedJSHeapSize ?? null,
};
};
const getRootTab = () => lastObservedRootRouteRef.current ?? currentState?.lastRootTab ?? "unknown";
const persistCurrentState = async (patch?: Partial<CrashSentinelState>) => {
if (!currentState) return;
currentState = {
...currentState,
...patch,
lastRootTab: getRootTab(),
previousRootNavigation: latestNavigationSnapshot ?? currentState.previousRootNavigation,
activeWebViews: Array.from(activeWebViews).sort(),
};
try {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(currentState));
} catch (error) {
Sentry.addBreadcrumb({
category: "app.lifecycle",
level: "warning",
message: "crash sentinel persist failed",
data: {
errorMessage: error instanceof Error ? error.message : String(error),
},
});
}
};
const shouldReportUnexpectedExit = (previous: CrashSentinelState, nowMs: number) => {
if (!previous.app_session_active) return false;
if (previous.normal_background) return false;
if (previous.reportedUnexpectedExitForSessionStartedAt === previous.sessionStartedAt) return false;
const heartbeatMs = Date.parse(previous.lastHeartbeatAt);
const startedMs = Date.parse(previous.sessionStartedAt);
if (!Number.isFinite(heartbeatMs) || !Number.isFinite(startedMs)) return false;
const heartbeatAgeMs = nowMs - heartbeatMs;
const sessionAgeMs = heartbeatMs - startedMs;
return heartbeatAgeMs >= 0 && heartbeatAgeMs <= RECENT_HEARTBEAT_MS && sessionAgeMs >= MIN_SESSION_AGE_MS;
};
const reportUnexpectedExit = async (previous: CrashSentinelState, nativeScreensMode: string) => {
const rootTab = getRootTab();
Sentry.captureMessage("app.previous_session_unexpected_exit", {
level: "warning",
tags: {
area: "app_lifecycle",
result: "unexpected_exit",
platform: Platform.OS,
root_tab: rootTab,
last_root_tab: previous.lastRootTab ?? "unknown",
native_screens_mode: nativeScreensMode,
},
contexts: {
app_lifecycle: {
lastHeartbeatAt: previous.lastHeartbeatAt,
sessionStartedAt: previous.sessionStartedAt,
previousRootNavigation: previous.previousRootNavigation,
lastKnownAppState: previous.lastKnownAppState,
activeWebViews: previous.activeWebViews,
memory: getMemoryInfo(),
expoUpdate: getExpoUpdateContext(),
normalBackground: previous.normal_background,
heartbeatAgeMs: Date.now() - Date.parse(previous.lastHeartbeatAt),
},
},
fingerprint: ["app_lifecycle", "unexpected_exit", Platform.OS],
});
await AsyncStorage.setItem(
STORAGE_KEY,
JSON.stringify({
...previous,
reportedUnexpectedExitForSessionStartedAt: previous.sessionStartedAt,
})
);
};
export async function startAppLifecycleCrashSentinel(options: StartOptions) {
const startedAt = nowIso();
const previous = safeJsonParse<CrashSentinelState>(await AsyncStorage.getItem(STORAGE_KEY));
if (previous && shouldReportUnexpectedExit(previous, Date.now())) {
await reportUnexpectedExit(previous, options.nativeScreensMode);
}
currentState = {
app_session_active: true,
normal_background: false,
sessionStartedAt: startedAt,
lastHeartbeatAt: startedAt,
lastKnownAppState: AppState.currentState ?? "unknown",
lastRootTab: getRootTab(),
previousRootNavigation: latestNavigationSnapshot,
activeWebViews: Array.from(activeWebViews).sort(),
nativeScreensMode: options.nativeScreensMode,
};
Sentry.setContext("app_lifecycle_sentinel", {
sessionStartedAt: currentState.sessionStartedAt,
lastHeartbeatAt: currentState.lastHeartbeatAt,
lastKnownAppState: currentState.lastKnownAppState,
activeWebViews: currentState.activeWebViews,
expoUpdate: getExpoUpdateContext(),
});
await persistCurrentState();
if (heartbeatTimer) clearInterval(heartbeatTimer);
heartbeatTimer = setInterval(() => {
void persistCurrentState({
lastHeartbeatAt: nowIso(),
lastKnownAppState: AppState.currentState ?? "unknown",
normal_background: AppState.currentState === "background" ? true : currentState?.normal_background ?? false,
});
}, HEARTBEAT_INTERVAL_MS);
appStateSubscription?.remove();
appStateSubscription = AppState.addEventListener("change", (nextState) => {
const patch: Partial<CrashSentinelState> = {
lastKnownAppState: nextState,
lastHeartbeatAt: nowIso(),
};
if (nextState === "background") {
patch.normal_background = true;
} else if (nextState === "active") {
patch.normal_background = false;
}
void persistCurrentState(patch);
});
}
export function stopAppLifecycleCrashSentinel() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
appStateSubscription?.remove();
appStateSubscription = null;
}
export function recordAppLifecycleRootNavigation(snapshot: Omit<RootNavigationSnapshot, "updatedAt">) {
latestNavigationSnapshot = {
...snapshot,
updatedAt: nowIso(),
};
if (snapshot.rootRoute) {
lastObservedRootRouteRef.current = snapshot.rootRoute;
}
void persistCurrentState();
}
export function setAppLifecycleWebViewActive(name: string, active: boolean) {
if (active) {
activeWebViews.add(name);
} else {
activeWebViews.delete(name);
}
Sentry.setContext("active_webviews", {
names: Array.from(activeWebViews).sort(),
});
void persistCurrentState();
}
+28
View File
@@ -0,0 +1,28 @@
import { DataEndpoint, ExpectedContentType } from "./types";
type DataEndpointConfig = {
expectedContentType: ExpectedContentType;
timeoutMs: number;
slowMs: number;
allowStale: boolean;
};
export const DATA_ENDPOINT_CONFIG: Record<DataEndpoint, DataEndpointConfig> = {
positions_current: { expectedContentType: "json", timeoutMs: 8000, slowMs: 2500, allowStale: true },
positions_master: { expectedContentType: "json", timeoutMs: 8000, slowMs: 2500, allowStale: true },
positions_gas_fallback: { expectedContentType: "json", timeoutMs: 8000, slowMs: 2500, allowStale: true },
positions_webview: { expectedContentType: "json", timeoutMs: 8000, slowMs: 2500, allowStale: true },
operation_info_flag: { expectedContentType: "json", timeoutMs: 10000, slowMs: 3000, allowStale: true },
operation_info_text: { expectedContentType: "text", timeoutMs: 15000, slowMs: 5000, allowStale: true },
operation_logs: { expectedContentType: "json", timeoutMs: 15000, slowMs: 3000, allowStale: true },
train_operation_data: { expectedContentType: "json", timeoutMs: 15000, slowMs: 3500, allowStale: true },
timetable_today: { expectedContentType: "json", timeoutMs: 15000, slowMs: 3500, allowStale: true },
positions: { expectedContentType: "json", timeoutMs: 8000, slowMs: 2500, allowStale: true },
operation_info: { expectedContentType: "json", timeoutMs: 10000, slowMs: 3000, allowStale: true },
train_operation: { expectedContentType: "json", timeoutMs: 10000, slowMs: 3500, allowStale: true },
timetable: { expectedContentType: "json", timeoutMs: 10000, slowMs: 3500, allowStale: true },
station_info: { expectedContentType: "json", timeoutMs: 8000, slowMs: 2500, allowStale: true },
notice: { expectedContentType: "json", timeoutMs: 8000, slowMs: 2500, allowStale: true },
app_config: { expectedContentType: "json", timeoutMs: 8000, slowMs: 2000, allowStale: false },
unknown: { expectedContentType: "any", timeoutMs: 8000, slowMs: 3000, allowStale: false },
};
+31
View File
@@ -0,0 +1,31 @@
import { NetworkFailureKind } from "./types";
export class ObservedFetchError extends Error {
kind: NetworkFailureKind;
status?: number;
retryable: boolean;
constructor(message: string, kind: NetworkFailureKind, options?: { status?: number; retryable?: boolean; cause?: unknown }) {
super(message);
this.name = "ObservedFetchError";
this.kind = kind;
this.status = options?.status;
this.retryable = options?.retryable ?? false;
if (options?.cause !== undefined) {
(this as Error & { cause?: unknown }).cause = options.cause;
}
}
}
export const isObservedFetchError = (error: unknown): error is ObservedFetchError =>
error instanceof ObservedFetchError ||
(typeof error === "object" && error !== null && (error as any).name === "ObservedFetchError");
export const shouldRetryNetworkError = (error: unknown) => {
if (isObservedFetchError(error)) {
return error.retryable;
}
const message = String((error as any)?.message ?? error);
return message.includes("Network request failed") || message.includes("AbortError");
};
+377
View File
@@ -0,0 +1,377 @@
import { AppState, Platform } from "react-native";
import * as Sentry from "@sentry/react-native";
import { DATA_ENDPOINT_CONFIG } from "./endpoints";
import { ObservedFetchOptions } from "./types";
import { ObservedFetchError, shouldRetryNetworkError } from "./networkError";
import { reportDataFetchFailure, reportDataFetchSuccess, truncateResponseHead } from "./sentryNetwork";
import { lastObservedRootRouteRef, rootNavigationRef } from "@/lib/rootNavigation";
type ParsedUrl = { host: string; pathTemplate: string };
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const getRootTab = (fallback?: string) => {
if (fallback) return fallback;
try {
const state = rootNavigationRef.getState?.();
const routeName = state?.routes?.[state.index ?? 0]?.name;
if (routeName) {
lastObservedRootRouteRef.current = routeName;
return routeName;
}
} catch {}
return lastObservedRootRouteRef.current ?? "unknown";
};
const getKnownOnline = () => {
const online = (globalThis as any)?.navigator?.onLine;
return typeof online === "boolean" ? online : true;
};
const getRuntimeState = () => ({
appState: AppState.currentState,
online: getKnownOnline(),
});
const canStartFetch = (options: ObservedFetchOptions) => {
const runtime = getRuntimeState();
return (options.allowBackground || runtime.appState !== "background") && runtime.online !== false;
};
const shouldCaptureRuntimeFailure = () => {
const runtime = getRuntimeState();
return runtime.appState !== "background" && runtime.online !== false;
};
const getOptionTags = (options: ObservedFetchOptions) => ({
source: options.source ?? "rn_fetch",
user_visible: String(options.userVisible ?? false),
preload: String(options.preload ?? false),
fetch_priority: options.fetchPriority ?? "medium",
});
const safeParseUrl = (url: string, urlPathTemplate?: string): ParsedUrl => {
try {
const parsed = new URL(url);
return {
host: parsed.host,
pathTemplate: urlPathTemplate ?? parsed.pathname,
};
} catch {
return { host: "unknown", pathTemplate: urlPathTemplate ?? "unknown" };
}
};
const estimateBytes = (text: string) => {
if (typeof Blob !== "undefined") {
return new Blob([text]).size;
}
return text.length;
};
const looksJson = (contentType: string, text: string) => {
const trimmed = text.trimStart();
return contentType.includes("application/json") || trimmed.startsWith("{") || trimmed.startsWith("[");
};
const getFetchSignal = (controller: AbortController, externalSignal?: AbortSignal) => {
if (!externalSignal) return controller.signal;
if (externalSignal.aborted) controller.abort();
const abort = () => controller.abort();
externalSignal.addEventListener("abort", abort, { once: true });
return controller.signal;
};
const buildContext = (args: {
endpoint: string;
status?: number;
ok?: boolean;
durationMs: number;
timeoutMs: number;
bytes?: number;
contentType?: string;
responseHead?: string;
urlHost: string;
urlPathTemplate: string;
retryCount: number;
}) => ({
endpoint: args.endpoint,
method: "GET",
status: args.status,
ok: args.ok,
durationMs: args.durationMs,
timeoutMs: args.timeoutMs,
bytes: args.bytes,
contentType: args.contentType,
responseHead: truncateResponseHead(args.responseHead),
retryCount: args.retryCount,
urlHost: args.urlHost,
urlPathTemplate: args.urlPathTemplate,
...getRuntimeState(),
});
async function observedFetchTextOnce(url: string, options: ObservedFetchOptions, reportSuccess = true): Promise<{ text: string; response: Response; durationMs: number; bytes: number; contentType: string; context: Record<string, unknown>; slow: boolean; statusTag: string }> {
const endpoint = options.endpoint ?? "unknown";
const config = DATA_ENDPOINT_CONFIG[endpoint];
const timeoutMs = options.timeoutMs ?? config.timeoutMs;
const slowMs = options.slowMs ?? config.slowMs;
const startedAt = Date.now();
const parsedUrl = safeParseUrl(url, options.urlPathTemplate);
const retryCount = options.retryCount ?? 0;
const method = options.method ?? "GET";
const rootTab = getRootTab(options.rootTab);
const baseTags = {
area: "data_fetch",
endpoint,
result: "unknown",
root_tab: rootTab,
platform: Platform.OS,
...getOptionTags(options),
};
if (!canStartFetch(options)) {
const runtime = getRuntimeState();
const error = new ObservedFetchError(
runtime.online === false ? "Data fetch skipped while offline" : "Data fetch skipped while app is background",
"aborted",
{ retryable: false }
);
reportDataFetchFailure({
kind: "aborted",
error,
tags: { ...baseTags, result: "aborted" },
context: buildContext({
endpoint,
durationMs: 0,
timeoutMs,
urlHost: parsedUrl.host,
urlPathTemplate: parsedUrl.pathTemplate,
retryCount,
}),
capture: false,
});
throw error;
}
const controller = new AbortController();
const signal = getFetchSignal(controller, options.signal);
let timedOut = false;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
timedOut = true;
controller.abort();
reject(new Error("Network request timed out"));
}, timeoutMs);
});
Sentry.addBreadcrumb({
category: "data_fetch",
level: "info",
message: "fetch:start",
data: { endpoint, method, urlHost: parsedUrl.host, urlPathTemplate: parsedUrl.pathTemplate, timeoutMs, retryCount },
});
const run = async () => {
try {
const { endpoint: _endpoint, expectedContentType: _expectedContentType, timeoutMs: _timeoutMs, slowMs: _slowMs, rootTab: _rootTab, source: _source, userVisible: _userVisible, preload: _preload, fetchPriority: _fetchPriority, urlPathTemplate: _urlPathTemplate, retryCount: _retryCount, retry: _retry, allowBackground: _allowBackground, signal: _signal, ...requestOptions } = options;
const { response, text, contentType } = await Promise.race([
(async () => {
const response = await fetch(url, {
...requestOptions,
signal,
});
const contentType = response.headers.get("content-type") ?? "";
const text = await response.text();
return { response, text, contentType };
})(),
timeoutPromise,
]);
const durationMs = Date.now() - startedAt;
const bytes = estimateBytes(text);
const context = buildContext({
endpoint,
status: response.status,
ok: response.ok,
durationMs,
timeoutMs,
bytes,
contentType,
urlHost: parsedUrl.host,
urlPathTemplate: parsedUrl.pathTemplate,
retryCount,
});
if (!response.ok) {
const retryable = [502, 503, 504].includes(response.status);
const error = new ObservedFetchError("HTTP " + response.status, "http_error", { status: response.status, retryable });
reportDataFetchFailure({
kind: "http_error",
error,
tags: { ...baseTags, result: "http_error", status: String(response.status) },
context: { ...context, responseHead: truncateResponseHead(text) },
});
throw error;
}
const slow = durationMs >= slowMs;
if (reportSuccess) {
reportDataFetchSuccess({
tags: { ...baseTags, result: slow ? "slow_success" : "success", status: String(response.status) },
context,
slow,
});
}
return { text, response, durationMs, bytes, contentType, context, slow, statusTag: String(response.status) };
} catch (error) {
if (error instanceof ObservedFetchError) throw error;
const durationMs = Date.now() - startedAt;
const isAbort = timedOut || (error as any)?.name === "AbortError";
const kind = isAbort ? "timeout" : String((error as any)?.message ?? "").includes("Network request failed") ? "network_error" : "unknown";
const retryable = kind === "timeout" || kind === "network_error";
const wrapped = new ObservedFetchError(
isAbort ? "Network request timed out" : String((error as any)?.message ?? error),
kind,
isAbort ? { retryable } : { retryable, cause: error }
);
reportDataFetchFailure({
kind,
error: wrapped,
tags: { ...baseTags, result: kind },
context: buildContext({
endpoint,
durationMs,
timeoutMs,
urlHost: parsedUrl.host,
urlPathTemplate: parsedUrl.pathTemplate,
retryCount,
}),
capture: shouldCaptureRuntimeFailure(),
});
throw wrapped;
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
};
const spanOptions = {
name: "fetch " + endpoint,
description: "fetch " + endpoint,
op: "http.client",
attributes: {
"app.area": "data_fetch",
"app.endpoint": endpoint,
"app.root_tab": rootTab,
"http.method": method,
"server.address": parsedUrl.host,
},
};
if (typeof (Sentry as any).startSpan === "function") {
return (Sentry as any).startSpan(spanOptions, run);
}
return run();
}
export async function observedFetchText(url: string, options: ObservedFetchOptions): Promise<string> {
const retryCount = options.retryCount ?? 0;
try {
const result = await observedFetchTextOnce(url, { ...options, retryCount });
return result.text;
} catch (error) {
if (!options.retry || retryCount >= 1 || !shouldRetryNetworkError(error)) {
throw error;
}
if (!canStartFetch(options)) {
throw error;
}
await sleep(750 + Math.random() * 500);
return observedFetchText(url, { ...options, retryCount: retryCount + 1 });
}
}
export async function observedFetchJson<T>(url: string, options: ObservedFetchOptions): Promise<T> {
const endpoint = options.endpoint ?? "unknown";
const config = DATA_ENDPOINT_CONFIG[endpoint];
const expectedContentType = options.expectedContentType ?? config.expectedContentType;
const parsedUrl = safeParseUrl(url, options.urlPathTemplate);
const retryCount = options.retryCount ?? 0;
const rootTab = getRootTab(options.rootTab);
try {
const result = await observedFetchTextOnce(url, { ...options, retryCount }, false);
const trimmed = result.text.trim();
const context = buildContext({
endpoint,
status: result.response.status,
ok: result.response.ok,
durationMs: result.durationMs,
timeoutMs: options.timeoutMs ?? config.timeoutMs,
bytes: result.bytes,
contentType: result.contentType,
urlHost: parsedUrl.host,
urlPathTemplate: parsedUrl.pathTemplate,
retryCount,
});
if (trimmed.length === 0) {
const error = new ObservedFetchError("Empty response", "empty_response", { retryable: false });
reportDataFetchFailure({
kind: "empty_response",
error,
tags: { area: "data_fetch", endpoint, result: "empty_response", root_tab: rootTab, platform: Platform.OS, ...getOptionTags(options), status: String(result.response.status) },
context,
});
throw error;
}
if (expectedContentType === "json" && !looksJson(result.contentType, result.text)) {
const error = new ObservedFetchError("Non JSON response", "non_json_response", { retryable: false });
reportDataFetchFailure({
kind: "non_json_response",
error,
tags: { area: "data_fetch", endpoint, result: "non_json", root_tab: rootTab, platform: Platform.OS, ...getOptionTags(options), status: String(result.response.status) },
context: { ...context, responseHead: truncateResponseHead(result.text) },
});
throw error;
}
try {
const parsed = JSON.parse(result.text) as T;
reportDataFetchSuccess({
tags: {
area: "data_fetch",
endpoint,
result: result.slow ? "slow_success" : "success",
root_tab: rootTab,
platform: Platform.OS,
...getOptionTags(options),
status: result.statusTag,
},
context,
slow: result.slow,
});
return parsed;
} catch (error) {
const wrapped = new ObservedFetchError("JSON parse error", "json_parse_error", { retryable: false, cause: error });
reportDataFetchFailure({
kind: "json_parse_error",
error: wrapped,
tags: { area: "data_fetch", endpoint, result: "parse_error", root_tab: rootTab, platform: Platform.OS, ...getOptionTags(options), status: String(result.response.status) },
context: { ...context, responseHead: truncateResponseHead(result.text) },
});
throw wrapped;
}
} catch (error) {
if (!options.retry || retryCount >= 1 || !shouldRetryNetworkError(error)) {
throw error;
}
if (!canStartFetch(options)) {
throw error;
}
await sleep(750 + Math.random() * 500);
return observedFetchJson<T>(url, { ...options, retryCount: retryCount + 1 });
}
}
@@ -0,0 +1,60 @@
import * as Sentry from "@sentry/react-native";
import { NetworkFailureKind } from "./types";
const SLOW_SUCCESS_SAMPLE_RATE = 0;
export const truncateResponseHead = (value: unknown) =>
typeof value === "string" ? value.slice(0, 300) : value;
export function reportDataFetchSuccess(args: {
tags: Record<string, string>;
context: Record<string, unknown>;
slow: boolean;
}) {
Sentry.addBreadcrumb({
category: "data_fetch",
level: args.slow ? "warning" : "info",
message: args.slow ? "fetch:slow_success" : "fetch:success",
data: args.context,
});
if (args.slow && SLOW_SUCCESS_SAMPLE_RATE > 0 && Math.random() < SLOW_SUCCESS_SAMPLE_RATE) {
Sentry.captureMessage("data_fetch.slow_success", {
level: "warning",
tags: args.tags,
contexts: { data_fetch: args.context },
});
}
}
export function reportDataFetchFailure(args: {
kind: NetworkFailureKind;
error: unknown;
tags: Record<string, string>;
context: Record<string, unknown>;
capture?: boolean;
}) {
Sentry.addBreadcrumb({
category: "data_fetch",
level: args.kind === "aborted" ? "info" : "error",
message: "fetch:" + args.kind,
data: args.context,
});
if (args.kind === "aborted" || args.capture === false) return;
const level = args.kind === "json_parse_error" ? "error" : "warning";
const captureError = args.error instanceof Error ? args.error : new Error(String(args.error));
Sentry.captureException(captureError, {
level,
tags: args.tags,
contexts: {
data_fetch: {
kind: args.kind,
...args.context,
},
},
fingerprint: ["data_fetch", args.kind, args.tags.endpoint ?? "unknown"],
});
}
+70
View File
@@ -0,0 +1,70 @@
export type DataEndpoint =
| "positions_current"
| "positions_master"
| "positions_gas_fallback"
| "positions_webview"
| "operation_info_flag"
| "operation_info_text"
| "operation_logs"
| "train_operation_data"
| "timetable_today"
| "positions"
| "operation_info"
| "train_operation"
| "timetable"
| "station_info"
| "notice"
| "app_config"
| "unknown";
export type ExpectedContentType = "json" | "text" | "html" | "any";
export type DataFetchSource =
| "rn_fetch"
| "mock_api"
| "n8n"
| "gas"
| "backend_api"
| "static_storage"
| "webview_fetch";
export type FetchPriority = "high" | "medium" | "low";
export type DataFetchResult =
| "success"
| "slow_success"
| "network_error"
| "timeout"
| "http_error"
| "non_json"
| "parse_error"
| "empty_response"
| "aborted"
| "unknown";
export type NetworkFailureKind =
| "timeout"
| "network_error"
| "http_error"
| "non_json_response"
| "json_parse_error"
| "empty_response"
| "aborted"
| "unknown";
export type ObservedFetchOptions = Omit<RequestInit, "signal"> & {
endpoint: DataEndpoint;
expectedContentType?: ExpectedContentType;
timeoutMs?: number;
slowMs?: number;
rootTab?: string;
source?: DataFetchSource;
userVisible?: boolean;
preload?: boolean;
fetchPriority?: FetchPriority;
urlPathTemplate?: string;
retryCount?: number;
retry?: boolean;
allowBackground?: boolean;
signal?: AbortSignal;
};
+41
View File
@@ -2,9 +2,29 @@ import { createNavigationContainerRef, StackActions } from "@react-navigation/na
export const rootNavigationRef = createNavigationContainerRef<any>(); export const rootNavigationRef = createNavigationContainerRef<any>();
export const lastObservedRootRouteRef: { current: string | null } = { current: null };
export const startupExplicitTargetRef: { current: boolean } = { current: false };
const startupPendingResolversRef: { current: number } = { current: 2 };
/** positions タブの Stack.Navigator navigation を登録するグローバルref */ /** positions タブの Stack.Navigator navigation を登録するグローバルref */
export const positionsStackNavRef: { current: any } = { current: null }; export const positionsStackNavRef: { current: any } = { current: null };
export const positionsLifecycleRef: {
current: {
isUnstable: boolean;
blockTabExit: boolean;
resetBeforeLeave: (() => void) | null;
deactivateStack: (() => void) | null;
};
} = {
current: {
isUnstable: false,
blockTabExit: false,
resetBeforeLeave: null,
deactivateStack: null,
},
};
/** /**
* popToTop * popToTop
* 使 * 使
@@ -45,3 +65,24 @@ export function stackAwareNavigate(tabName: string, params?: any) {
doNavigate(); doNavigate();
} }
} }
export function markStartupExplicitTarget() {
startupExplicitTargetRef.current = true;
}
export function resolveStartupNavigationSource() {
startupPendingResolversRef.current = Math.max(
0,
startupPendingResolversRef.current - 1
);
}
export async function waitForStartupNavigationResolution(timeoutMs = 2000) {
const startedAt = Date.now();
while (startupPendingResolversRef.current > 0) {
if (Date.now() - startedAt >= timeoutMs) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
+9 -1
View File
@@ -28,8 +28,9 @@ const forModalPresentationAndroid = (
export const optionData = { export const optionData = {
gestureEnabled: true, gestureEnabled: true,
...TransitionPresets.ModalPresentationIOS, ...(Platform.OS === "ios" ? TransitionPresets.ModalPresentationIOS : {}),
...(Platform.OS === "android" && { ...(Platform.OS === "android" && {
animationEnabled: false,
cardStyleInterpolator: forModalPresentationAndroid, cardStyleInterpolator: forModalPresentationAndroid,
}), }),
cardOverlayEnabled: true, cardOverlayEnabled: true,
@@ -37,3 +38,10 @@ export const optionData = {
headerShown: false, headerShown: false,
detachPreviousScreen: false, detachPreviousScreen: false,
}; };
export const pushTransitionOptions =
Platform.OS === "ios"
? TransitionPresets.SlideFromRightIOS
: {
animationEnabled: false,
};
+44 -12
View File
@@ -1,4 +1,5 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { checkDuplicateTrainData } from "@/lib/checkDuplicateTrainData";
import { trainDataType, trainPosition } from "@/lib/trainPositionTextArray"; import { trainDataType, trainPosition } from "@/lib/trainPositionTextArray";
import { eachTrainDiagramType, StationProps } from "@/lib/CommonTypes"; import { eachTrainDiagramType, StationProps } from "@/lib/CommonTypes";
import betweenData from "@/assets/originData/between"; import betweenData from "@/assets/originData/between";
@@ -6,12 +7,15 @@ type trainDataProps = {
d: eachTrainDiagramType; d: eachTrainDiagramType;
currentTrain: trainDataType[]; currentTrain: trainDataType[];
station: StationProps[]; station: StationProps[];
stationList: StationProps[][];
now?: string | null;
}; };
export const trainTimeFiltering: (x: trainDataProps) => boolean = (props) => { export const trainTimeFiltering: (x: trainDataProps) => boolean = (props) => {
const { d, currentTrain, station } = props; const { d, currentTrain, station, stationList, now } = props;
const baseTime = 2; // 何時間以内の列車を表示するか const baseTime = 2; // 何時間以内の列車を表示するか
if (currentTrain.filter((t) => t.num == d.train).length == 0) { const currentTrainMatches = currentTrain.filter((t) => t.num == d.train);
const date = dayjs(); if (currentTrainMatches.length == 0) {
const date = now ? dayjs(now) : dayjs();
const trainTime = date const trainTime = date
.hour(parseInt(d.time.split(":")[0])) .hour(parseInt(d.time.split(":")[0]))
.minute(parseInt(d.time.split(":")[1])); .minute(parseInt(d.time.split(":")[1]));
@@ -23,7 +27,10 @@ export const trainTimeFiltering: (x: trainDataProps) => boolean = (props) => {
} }
return false; return false;
} else { } else {
const Pos = trainPosition(currentTrain.filter((t) => t.num == d.train)[0]); const currentTrainData =
checkDuplicateTrainData(currentTrainMatches, stationList) ??
currentTrainMatches[0];
const Pos = trainPosition(currentTrainData);
let nextPos = ""; let nextPos = "";
let PrePos = ""; let PrePos = "";
// //
@@ -65,9 +72,9 @@ export const trainTimeFiltering: (x: trainDataProps) => boolean = (props) => {
break; break;
} }
const [h, m] = d.time.split(":"); const [h, m] = d.time.split(":");
const delayData = currentTrain.filter((t) => t.num == d.train)[0].delay; const delayData = currentTrainData.delay;
let delay = delayData === "入線" ? 0 : delayData; let delay = delayData === "入線" ? 0 : delayData;
const date = dayjs(); const date = now ? dayjs(now) : dayjs();
const IntH = parseInt(h); const IntH = parseInt(h);
const IntM = parseInt(m); const IntM = parseInt(m);
const currentHour = date.hour(); const currentHour = date.hour();
@@ -96,6 +103,15 @@ type getTimeProps = (
export const getTime: getTimeProps = (stationDiagram, station) => { export const getTime: getTimeProps = (stationDiagram, station) => {
const returnData = Object.keys(stationDiagram) const returnData = Object.keys(stationDiagram)
.map((trainNum) => { .map((trainNum) => {
const diagramEntries = stationDiagram[trainNum]
.split("#")
.map((data) => {
const [stationName, type, time, platformNum] = data.split(",");
return { stationName, type, time, platformNum };
})
.filter((entry) => entry.stationName && entry.type && entry.time);
const firstTimedEntry = diagramEntries[0];
let trainData: eachTrainDiagramType = { let trainData: eachTrainDiagramType = {
time: "", time: "",
lastStation: "", lastStation: "",
@@ -103,9 +119,10 @@ export const getTime: getTimeProps = (stationDiagram, station) => {
train: trainNum, train: trainNum,
platformNum: null, platformNum: null,
se: undefined, se: undefined,
arrivalTime: undefined,
departureTime: undefined,
}; };
stationDiagram[trainNum].split("#").forEach((data) => { diagramEntries.forEach(({ stationName, type, time, platformNum }) => {
const [stationName, type, time, platformNum] = data.split(",");
if (!type) return; if (!type) return;
if (type.match("着")) { if (type.match("着")) {
trainData.lastStation = stationName; trainData.lastStation = stationName;
@@ -115,21 +132,36 @@ export const getTime: getTimeProps = (stationDiagram, station) => {
trainData.se = type; trainData.se = type;
if (type.match("発")) { if (type.match("発")) {
trainData.time = time; trainData.time = time;
} else if (type.match("通")) { trainData.departureTime = time;
}
if (type.match("着")) {
trainData.arrivalTime = time;
if (!trainData.departureTime) {
trainData.time = time;
}
}
if (type.match("通")) {
trainData.time = time; trainData.time = time;
trainData.isThrough = true; trainData.isThrough = true;
} else if (type.match("着")) {
trainData.time = time;
} }
} }
}); });
return { return {
train: trainNum, train: trainNum,
time: trainData.time, time:
trainData.departureTime ||
trainData.arrivalTime ||
trainData.time,
lastStation: trainData.lastStation, lastStation: trainData.lastStation,
isThrough: trainData.isThrough, isThrough: trainData.isThrough,
platformNum: trainData.platformNum, platformNum: trainData.platformNum,
se: trainData.se, se: trainData.se,
arrivalTime: trainData.arrivalTime,
departureTime: trainData.departureTime,
isOrigin:
firstTimedEntry?.stationName === station.Station_JP &&
!!trainData.departureTime &&
!trainData.arrivalTime,
}; };
}) })
.filter((d) => d.time); .filter((d) => d.time);
+23
View File
@@ -0,0 +1,23 @@
const WRAPPABLE_TYPE_LENGTH = 4;
const MIN_LONG_TRAIN_NAME_LENGTH = 8;
const countCharacters = (value: string) => Array.from(value).length;
const normalizeTrainName = (trainName: string) => trainName.replace(/\s+/g, "");
export const shouldWrapTrainType = (
typeName: string,
trainName: string,
): boolean =>
countCharacters(typeName) === WRAPPABLE_TYPE_LENGTH &&
countCharacters(normalizeTrainName(trainName)) >= MIN_LONG_TRAIN_NAME_LENGTH;
export const formatWrappedTrainType = (typeName: string): string => {
const chars = Array.from(typeName);
if (chars.length !== WRAPPABLE_TYPE_LENGTH) {
return typeName;
}
return `${chars.slice(0, 2).join("")}\n${chars.slice(2).join("")}`;
};
+95 -15
View File
@@ -2,6 +2,26 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { AppState, AppStateStatus } from "react-native"; import { AppState, AppStateStatus } from "react-native";
import WebView from "react-native-webview"; import WebView from "react-native-webview";
type WebViewRemountReason =
| "manual"
| "app_background"
| "render_process_gone"
| "content_process_terminated"
| "loading_timeout"
| "pong_timeout"
| "blank_detected";
type WebViewRemountData = Record<string, string | number | boolean | null | undefined>;
type UseWebViewRemountOptions = {
pingEnabled?: boolean;
backgroundThresholdMs?: number | null;
isFocused?: boolean;
pauseWatchdogWhenUnfocused?: boolean;
ignoreProcessTerminationWhenUnfocused?: boolean;
onRemount?: (reason: WebViewRemountReason, data?: WebViewRemountData) => void;
};
/** /**
* WebView * WebView
* *
@@ -13,25 +33,55 @@ import WebView from "react-native-webview";
* pingHandlers 使 onMessage spread * pingHandlers 使 onMessage spread
* ping pingEnabled: true * ping pingEnabled: true
*/ */
export function useWebViewRemount(options?: { pingEnabled?: boolean; backgroundThresholdMs?: number }) { export function useWebViewRemount(options?: UseWebViewRemountOptions) {
const pingEnabled = options?.pingEnabled ?? false; const pingEnabled = options?.pingEnabled ?? false;
const backgroundThresholdMs = options?.backgroundThresholdMs ?? 300_000; // デフォルト5分 const backgroundThresholdMs = options?.backgroundThresholdMs ?? null;
const isFocused = options?.isFocused ?? true;
const pauseWatchdogWhenUnfocused = options?.pauseWatchdogWhenUnfocused ?? false;
const ignoreProcessTerminationWhenUnfocused = options?.ignoreProcessTerminationWhenUnfocused ?? false;
const onRemount = options?.onRemount;
const [remountKey, setRemountKey] = useState(0); const [remountKey, setRemountKey] = useState(0);
const backgroundedAt = useRef<number | null>(null); const backgroundedAt = useRef<number | null>(null);
const webViewRef = useRef<WebView>(null); const webViewRef = useRef<WebView>(null);
const ignoredProcessTerminationRef = useRef<WebViewRemountReason | null>(null);
const focusedRef = useRef(isFocused);
const lastBlurAtRef = useRef<number | null>(null);
const processTerminationBlurGraceMs = 1200;
// ping watchdog 用 // ping watchdog 用
const lastPongAt = useRef(Date.now()); const lastPongAt = useRef(Date.now());
const isLoadingRef = useRef(true); const isLoadingRef = useRef(true);
const remount = useCallback(() => { const triggerRemount = useCallback((reason: WebViewRemountReason, data?: WebViewRemountData) => {
onRemount?.(reason, data);
lastPongAt.current = Date.now(); lastPongAt.current = Date.now();
isLoadingRef.current = true; isLoadingRef.current = true;
setRemountKey((k) => k + 1); setRemountKey((k) => k + 1);
}, []); }, [onRemount]);
const remount = useCallback(() => {
triggerRemount("manual");
}, [triggerRemount]);
// バックグラウンドから復帰したら再マウント(デフォルト5分超)
useEffect(() => { useEffect(() => {
focusedRef.current = isFocused;
if (!isFocused) {
lastBlurAtRef.current = Date.now();
}
}, [isFocused]);
const shouldIgnoreProcessTermination = useCallback(() => {
if (!ignoreProcessTerminationWhenUnfocused) return false;
if (!focusedRef.current) return true;
if (lastBlurAtRef.current === null) return false;
return Date.now() - lastBlurAtRef.current < processTerminationBlurGraceMs;
}, [ignoreProcessTerminationWhenUnfocused]);
// 明示指定がある場合のみ、長時間バックグラウンド復帰で再マウントする。
useEffect(() => {
if (backgroundThresholdMs === null || backgroundThresholdMs <= 0) {
return;
}
const onAppStateChange = (nextState: AppStateStatus) => { const onAppStateChange = (nextState: AppStateStatus) => {
if (nextState.match(/inactive|background/)) { if (nextState.match(/inactive|background/)) {
backgroundedAt.current = Date.now(); backgroundedAt.current = Date.now();
@@ -39,13 +89,13 @@ export function useWebViewRemount(options?: { pingEnabled?: boolean; backgroundT
const elapsed = Date.now() - backgroundedAt.current; const elapsed = Date.now() - backgroundedAt.current;
backgroundedAt.current = null; backgroundedAt.current = null;
if (elapsed > backgroundThresholdMs) { if (elapsed > backgroundThresholdMs) {
remount(); triggerRemount("app_background", { elapsedMs: elapsed });
} }
} }
}; };
const subscription = AppState.addEventListener("change", onAppStateChange); const subscription = AppState.addEventListener("change", onAppStateChange);
return () => subscription.remove(); return () => subscription.remove();
}, [remount, backgroundThresholdMs]); }, [backgroundThresholdMs, triggerRemount]);
// ping watchdog: 5秒ごとに生存確認と白画面検知を行う // ping watchdog: 5秒ごとに生存確認と白画面検知を行う
// - ローディング中(isLoadingRef=true)でも45秒超なら remount(レンダラー死亡でonLoadEndが来ないケース) // - ローディング中(isLoadingRef=true)でも45秒超なら remount(レンダラー死亡でonLoadEndが来ないケース)
@@ -55,29 +105,53 @@ export function useWebViewRemount(options?: { pingEnabled?: boolean; backgroundT
useEffect(() => { useEffect(() => {
if (!pingEnabled) return; if (!pingEnabled) return;
const id = setInterval(() => { const id = setInterval(() => {
if (pauseWatchdogWhenUnfocused && !isFocused) {
lastPongAt.current = Date.now();
return;
}
const elapsed = Date.now() - lastPongAt.current; const elapsed = Date.now() - lastPongAt.current;
if (isLoadingRef.current) { if (isLoadingRef.current) {
// ローディング中でも45秒超はレンダラー死亡と判定 // ローディング中でも45秒超はレンダラー死亡と判定
if (elapsed > 45_000) remount(); if (elapsed > 45_000) triggerRemount("loading_timeout", { elapsedMs: elapsed });
return; return;
} }
// ロード完了後30秒 pong 無応答 → レンダラー死亡 // ロード完了後30秒 pong 無応答 → レンダラー死亡
if (elapsed > 30_000) { if (elapsed > 30_000) {
remount(); triggerRemount("pong_timeout", { elapsedMs: elapsed });
return; return;
} }
webViewRef.current?.injectJavaScript( webViewRef.current?.injectJavaScript(
`(function(){var t=document.body?(document.body.innerText||'').replace(/\\s+/g,'').length:0;window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:'__ping',len:t}));})();true;` `(function(){var t=document.body?(document.body.innerText||'').replace(/\s+/g,'').length:0;window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:'__ping',len:t}));})();true;`
); );
}, 5_000); }, 5_000);
return () => clearInterval(id); return () => clearInterval(id);
}, [pingEnabled, remount]); }, [isFocused, pauseWatchdogWhenUnfocused, pingEnabled, triggerRemount]);
const processHandlers = { const processHandlers = {
onRenderProcessGone: () => remount(), onRenderProcessGone: (event?: any) => {
onContentProcessDidTerminate: () => remount(), if (shouldIgnoreProcessTermination()) {
ignoredProcessTerminationRef.current = "render_process_gone";
lastPongAt.current = Date.now();
return;
}
triggerRemount("render_process_gone", { didCrash: event?.nativeEvent?.didCrash ?? null });
},
onContentProcessDidTerminate: () => {
if (shouldIgnoreProcessTermination()) {
ignoredProcessTerminationRef.current = "content_process_terminated";
lastPongAt.current = Date.now();
return;
}
triggerRemount("content_process_terminated");
},
} as const; } as const;
useEffect(() => {
if (!isFocused || !ignoredProcessTerminationRef.current) return;
ignoredProcessTerminationRef.current = null;
lastPongAt.current = Date.now();
}, [isFocused]);
// pingHandlers は onLoadEnd と onMessage を提供する // pingHandlers は onLoadEnd と onMessage を提供する
// 既存の onMessage がある場合は手動でマージすること // 既存の onMessage がある場合は手動でマージすること
const pingHandlers = pingEnabled ? { const pingHandlers = pingEnabled ? {
@@ -90,7 +164,7 @@ export function useWebViewRemount(options?: { pingEnabled?: boolean; backgroundT
setTimeout(() => { setTimeout(() => {
if (!isLoadingRef.current) { if (!isLoadingRef.current) {
webViewRef.current?.injectJavaScript( webViewRef.current?.injectJavaScript(
`(function(){var t=document.body?(document.body.innerText||'').replace(/\\s+/g,'').length:0;window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:'__ping',len:t}));})();true;` `(function(){var t=document.body?(document.body.innerText||'').replace(/\s+/g,'').length:0;window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:'__ping',len:t}));})();true;`
); );
} }
}, 3000); }, 3000);
@@ -112,9 +186,15 @@ export function useWebViewRemount(options?: { pingEnabled?: boolean; backgroundT
if (maxTextLenRef.current > 20 && len < 5) { if (maxTextLenRef.current > 20 && len < 5) {
blankCountRef.current += 1; blankCountRef.current += 1;
if (blankCountRef.current >= 3) { if (blankCountRef.current >= 3) {
const blankCount = blankCountRef.current;
const maxTextLength = maxTextLenRef.current;
blankCountRef.current = 0; blankCountRef.current = 0;
maxTextLenRef.current = 0; maxTextLenRef.current = 0;
remount(); triggerRemount("blank_detected", {
blankCount,
textLength: len,
maxTextLength,
});
} }
} else { } else {
blankCountRef.current = 0; blankCountRef.current = 0;
+807
View File
@@ -0,0 +1,807 @@
import dayjs from "dayjs";
import { STORAGE_KEYS } from "@/constants";
import type { CustomTrainData, StationProps, eachTrainDiagramType } from "@/lib/CommonTypes";
import { getTrainType } from "@/lib/getTrainType";
import { AS } from "@/storageControl";
import {
createVoicepeakAudioSource,
type PreparedVoicepeakAudio,
} from "@/lib/voicepeakAudioSource";
import {
completeVoicepeakDebugLog,
createVoicepeakDebugLog,
} from "@/lib/voicepeakDebugLog";
export const VOICEPEAK_PUBLIC_BASE_URL = "https://voicepeak-api.haruk.in";
export const VOICEPEAK_PUBLIC_SPEECH_PATH = "/v1/public/speech";
export const VOICEPEAK_ADVANCE_ATTEMPT_SECONDS = 150;
export const VOICEPEAK_DEPARTURE_ATTEMPT_SECONDS = 30;
export const VOICEPEAK_PASSING_GRACE_SECONDS = 15;
export const VOICEPEAK_SPEECH_TEXT_LIMIT = 135;
export const VOICEPEAK_REQUEST_TIMEOUT_MILLISECONDS = 90_000;
export type VoicepeakSettings = {
enabled: boolean;
};
export type VoicepeakAnnouncementStage = "advance" | "departure" | "passing";
export type VoicepeakSpeechOptions = {
force?: boolean;
signal?: AbortSignal;
};
type VoicepeakSpeechRequest = VoicepeakSpeechOptions & {
text: string;
settings: VoicepeakSettings;
debug?: {
batchId?: string;
chunkIndex?: number;
totalChunks?: number;
};
};
type VoicepeakErrorBody = {
error?: {
code?: string;
message?: string;
retryAfterSeconds?: number;
requestId?: string;
};
};
const readStoredString = async (key: string, fallback = "") => {
try {
const value = await AS.getItem(key);
return typeof value === "string" ? value : fallback;
} catch {
return fallback;
}
};
const replaceFullWidthSpacesWithSpeechPauses = (text: string) =>
text
.replace(/ +/g, "、")
.replace(/([、。!?!?])、+/g, "$1")
.replace(/、+([、。!?!?])/g, "$1")
.replace(/、{2,}/g, "、");
export const normalizeVoicepeakSpeechText = (text: string) =>
replaceFullWidthSpacesWithSpeechPauses(text).normalize("NFKC").trim();
export const countVoicepeakSpeechCodePoints = (text: string) =>
Array.from(text).length;
export const splitVoicepeakSpeechText = (
text: string,
maxLength = VOICEPEAK_SPEECH_TEXT_LIMIT
) => {
const normalizedText = normalizeVoicepeakSpeechText(text).trim();
if (!normalizedText) return [];
const safeMaxLength = Math.max(1, Math.min(maxLength, 140));
const remainingCharacters = Array.from(normalizedText);
const chunks: string[] = [];
const boundaryPriorities = [
new Set(["。", "", "", "\n"]),
new Set(["、", "", ","]),
new Set([" "]),
];
while (remainingCharacters.length > safeMaxLength) {
const searchableCharacterCount = safeMaxLength;
const searchableCharacters = remainingCharacters.slice(
0,
searchableCharacterCount
);
let splitIndex = -1;
for (const boundaries of boundaryPriorities) {
for (let index = searchableCharacters.length - 1; index >= 0; index--) {
if (boundaries.has(searchableCharacters[index])) {
splitIndex = index + 1;
break;
}
}
if (splitIndex > 0) break;
}
if (splitIndex <= 0) {
splitIndex = Math.max(1, searchableCharacterCount);
}
const chunk = remainingCharacters.splice(0, splitIndex).join("").trim();
if (chunk) chunks.push(chunk);
}
const finalChunk = remainingCharacters.join("").trim();
if (finalChunk) chunks.push(finalChunk);
return chunks;
};
export const loadVoicepeakSettings = async (): Promise<VoicepeakSettings> => ({
enabled:
(await readStoredString(STORAGE_KEYS.VOICEPEAK_ENABLED, "false")) === "true",
});
export const hasVoicepeakConfiguration = (settings: VoicepeakSettings) =>
settings.enabled;
export const buildVoicepeakAnnouncementKey = (
station: StationProps,
train: eachTrainDiagramType,
stage: VoicepeakAnnouncementStage
) => {
const [hourText] = train.time.split(":");
const hour = Number.parseInt(hourText, 10);
const serviceDate = dayjs()
.subtract(Number.isNaN(hour) ? 0 : hour < 4 ? 1 : 0, "day")
.format("YYYY-MM-DD");
return [
serviceDate,
stage,
station.StationNumber || station.Station_JP,
train.train,
train.time,
train.lastStation,
].join(":");
};
const getDepartureTiming = (timeText: string, delayMinutes = 0) => {
const [hourText, minuteText] = timeText.split(":");
const hour = Number.parseInt(hourText, 10);
const minute = Number.parseInt(minuteText, 10);
if (Number.isNaN(hour) || Number.isNaN(minute)) return null;
const now = dayjs();
const departureTime = now
.set("hour", hour < 4 ? hour + 24 : hour)
.set("minute", minute + delayMinutes)
.set("second", 0)
.set("millisecond", 0);
return {
now,
departureTime,
diffSeconds: departureTime.diff(now, "second"),
diffMilliseconds: departureTime.diff(now),
};
};
const getTrainNumberSuffix = (
trainId: string,
trainNumDistance: string | null | undefined
) => {
if (
trainNumDistance === undefined ||
trainNumDistance === null ||
trainNumDistance === "" ||
Number.isNaN(Number.parseInt(trainNumDistance, 10))
) {
return "";
}
const trainNumber =
Number.parseInt(trainId.replace(/\D/g, ""), 10) -
Number.parseInt(trainNumDistance, 10);
return Number.isNaN(trainNumber) ? "" : `${trainNumber}`;
};
const getEffectiveTrainDestination = (
train: eachTrainDiagramType,
currentTrainData: CustomTrainData
) => currentTrainData.to_data?.trim() || train.lastStation.trim();
const isTrainTerminatingAtStation = (
station: StationProps,
train: eachTrainDiagramType,
currentTrainData: CustomTrainData
) => {
const destination = getEffectiveTrainDestination(train, currentTrainData);
if (destination === "当駅止" || destination === "当駅止まり") return true;
const destinationStation = destination.replace(/(?:行き?|止まり)$/, "");
return destinationStation === station.Station_JP;
};
const buildTrainDestination = (
station: StationProps,
train: eachTrainDiagramType,
currentTrainData: CustomTrainData
) => {
const destination = getEffectiveTrainDestination(train, currentTrainData);
if (isTrainTerminatingAtStation(station, train, currentTrainData)) {
return `${station.Station_JP}止まり`;
}
return /行き?$/.test(destination) ? destination : `${destination}行き`;
};
const buildTrainLabel = (
trainTypeName: string,
trainName: string,
trainNumberSuffix: string
) => [trainTypeName, trainName, trainNumberSuffix].filter(Boolean).join("、");
export const getVoicepeakAnnouncementStage = ({
station,
train,
currentTrainData,
delayMinutes,
}: {
station: StationProps;
train: eachTrainDiagramType;
currentTrainData: CustomTrainData;
delayMinutes?: number;
}): VoicepeakAnnouncementStage | null => {
const trainType = getTrainType({ type: currentTrainData.type, id: train.train });
if (!train.isThrough && trainType.data === "notService") return null;
const passingDelayMinutes =
train.isThrough &&
typeof delayMinutes === "number" &&
Number.isFinite(delayMinutes) &&
delayMinutes > 0
? delayMinutes
: 0;
const timing = getDepartureTiming(train.time, passingDelayMinutes);
if (!timing) return null;
if (train.isThrough) {
if (train.se?.includes("休")) return null;
return timing.diffMilliseconds <= 0 &&
timing.diffMilliseconds > -VOICEPEAK_PASSING_GRACE_SECONDS * 1000
? "passing"
: null;
}
if (
timing.diffSeconds > 0 &&
timing.diffSeconds <= VOICEPEAK_DEPARTURE_ATTEMPT_SECONDS
) {
return "departure";
}
if (
timing.diffSeconds > 0 &&
timing.diffSeconds < VOICEPEAK_ADVANCE_ATTEMPT_SECONDS
) {
return "advance";
}
return null;
};
export const buildVoicepeakAnnouncementText = ({
station,
train,
currentTrainData,
stage,
delayMinutes,
isOrigin = false,
isStoppedAtStation = false,
advanceTimeBasis = "departure",
}: {
station: StationProps;
train: eachTrainDiagramType;
currentTrainData: CustomTrainData;
stage: VoicepeakAnnouncementStage;
delayMinutes?: number;
isOrigin?: boolean;
isStoppedAtStation?: boolean;
advanceTimeBasis?: "arrival" | "departure";
}) => {
const trainType = getTrainType({ type: currentTrainData.type, id: train.train });
const trainNumberSuffix = getTrainNumberSuffix(
train.train,
currentTrainData.train_num_distance
);
const destination = buildTrainDestination(station, train, currentTrainData);
const trainLabel = buildTrainLabel(
trainType.name,
currentTrainData.train_name,
trainNumberSuffix
);
const isTerminating = isTrainTerminatingAtStation(
station,
train,
currentTrainData
);
const hasArrivalTime =
!!train.arrivalTime || !!train.se?.includes("着");
const hasDepartureTime =
!!train.departureTime || !!train.se?.includes("発");
const isArrivalTimeOnly = hasArrivalTime && !hasDepartureTime;
const isArrivalBasedAdvance =
stage === "advance" &&
(isArrivalTimeOnly || advanceTimeBasis === "arrival");
const advanceLead = isStoppedAtStation
? train.platformNum?.trim()
? [`只今、${train.platformNum.trim()}番線に停車中の列車は`]
: ["只今、当駅に停車中の列車は"]
: train.platformNum?.trim()
? [`次の、${train.platformNum.trim()}番線に参ります列車は`]
: ["次の列車は"];
if (stage === "passing") {
const passingPlatform = train.platformNum?.trim()
? `${train.platformNum.trim()}番乗り場を`
: "ホームを";
return [
"間もなく",
passingPlatform,
"列車が通過します。",
"危険ですので",
"黄色い線、点字ブロックまでお下がりください。",
].join("、");
}
if (stage === "advance") {
const trainInfoText = currentTrainData.train_info?.trim();
const [hourText, minuteText] = train.time.split(":");
const departureTimeText = [
`${Number.parseInt(hourText || "0", 10)}`,
`${Number.parseInt(minuteText || "0", 10)}${
isArrivalBasedAdvance ? "着" : "発"
}`,
];
const parsedDelay =
typeof delayMinutes === "number" && Number.isFinite(delayMinutes) && delayMinutes > 0
? `${delayMinutes}分遅れで`
: "定刻で";
if (isTerminating) {
const terminatingDestination = `${station.Station_JP}行きです。`;
return [
...advanceLead,
"当駅止まりとなります、",
trainLabel,
terminatingDestination,
"現在",
parsedDelay,
"運転しております。",
"まもなく到着します。",
]
.filter(Boolean)
.join("、");
}
if (isOrigin && !isArrivalBasedAdvance) {
return [
...advanceLead,
"当駅始発となります、",
...departureTimeText,
trainLabel,
`${destination}です。`,
trainInfoText,
"まもなく発車します。",
]
.filter(Boolean)
.join("、");
}
const prefix = [
...advanceLead,
...departureTimeText,
trainLabel,
`${destination}です。`,
"この列車は",
"現在",
parsedDelay,
"運転しております。",
].join("、");
return [
prefix,
trainInfoText,
isArrivalBasedAdvance
? "まもなく到着します。ご注意ください。"
: "まもなく発車します。",
]
.filter(Boolean)
.join("、");
}
const platformText = train.platformNum?.trim()
? `${train.platformNum.trim()}番線${
isTerminating || isArrivalTimeOnly ? "に" : "より"
}`
: "";
if (isTerminating) {
return [
"間もなく",
platformText,
`当駅止まりの${trainLabel || "列車"}`,
"到着します。",
"ご注意ください。",
]
.filter(Boolean)
.join("、");
}
if (isArrivalTimeOnly) {
return [
"間もなく",
platformText,
trainLabel,
`${destination}の列車が`,
"到着します。",
"ご注意ください。",
]
.filter(Boolean)
.join("、");
}
const pieces = [
"間もなく",
platformText,
trainLabel,
destination,
"が",
"発車します。",
"ご注意ください。",
].filter(Boolean);
return pieces.join("、");
};
export class VoicepeakRequestError extends Error {
constructor(
message: string,
readonly status: number,
readonly code: string,
readonly retryAfterSeconds?: number,
readonly requestId?: string,
readonly retryable = false
) {
super(message);
this.name = "VoicepeakRequestError";
}
}
const parseOptionalNumber = (value: string | null) => {
if (value === null || value.trim() === "") return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
};
const getRetryAfterSeconds = (
response: Response,
body: VoicepeakErrorBody | null
) => {
const retryAfter = response.headers.get("Retry-After");
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds) && seconds >= 0) return seconds;
const retryAt = Date.parse(retryAfter);
if (Number.isFinite(retryAt)) {
return Math.max(0, Math.ceil((retryAt - Date.now()) / 1000));
}
}
const bodySeconds = body?.error?.retryAfterSeconds;
return typeof bodySeconds === "number" && Number.isFinite(bodySeconds)
? Math.max(0, bodySeconds)
: undefined;
};
const waitForRetry = (milliseconds: number, signal?: AbortSignal) =>
new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(new VoicepeakRequestError("Voicepeak request aborted", 0, "ABORTED"));
return;
}
const timeout = setTimeout(() => {
signal?.removeEventListener("abort", handleAbort);
resolve();
}, milliseconds);
const handleAbort = () => {
clearTimeout(timeout);
reject(new VoicepeakRequestError("Voicepeak request aborted", 0, "ABORTED"));
};
signal?.addEventListener("abort", handleAbort, { once: true });
});
const requestVoicepeakSpeechBytesOnce = async ({
text,
signal,
debug,
format,
attemptNumber,
force,
}: {
text: string;
signal?: AbortSignal;
debug?: VoicepeakSpeechRequest["debug"];
format: "mp3" | "wav";
attemptNumber: number;
force: boolean;
}) => {
const startedAt = Date.now();
const logId = await createVoicepeakDebugLog({
text,
format,
baseUrl: VOICEPEAK_PUBLIC_BASE_URL,
batchId: debug?.batchId,
chunkIndex: debug?.chunkIndex,
totalChunks: debug?.totalChunks,
attemptNumber,
forceRequested: force,
}).catch((error) => {
console.warn("Failed to create Voicepeak debug log", error);
return undefined;
});
let resultLogged = false;
const completeLog = async (
result: Parameters<typeof completeVoicepeakDebugLog>[1]
) => {
if (!logId) return;
await completeVoicepeakDebugLog(logId, result).catch((error) => {
console.warn("Failed to update Voicepeak debug log", error);
});
resultLogged = true;
};
const controller = new AbortController();
let timedOut = false;
const abortRequest = () => controller.abort();
if (signal?.aborted) controller.abort();
signal?.addEventListener("abort", abortRequest, { once: true });
const timeout = setTimeout(() => {
timedOut = true;
controller.abort();
}, VOICEPEAK_REQUEST_TIMEOUT_MILLISECONDS);
try {
const response = await fetch(
`${VOICEPEAK_PUBLIC_BASE_URL}${VOICEPEAK_PUBLIC_SPEECH_PATH}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
text,
format,
...(force ? { force: true } : {}),
}),
signal: controller.signal,
}
);
const headerRequestId = response.headers.get("X-Request-ID") || undefined;
const cacheStatus = response.headers.get("X-Voicepeak-Cache") || undefined;
const queueWaitMilliseconds = parseOptionalNumber(
response.headers.get("X-Voicepeak-Queue-Wait-Ms")
);
const queueDepth = parseOptionalNumber(
response.headers.get("X-Voicepeak-Queue-Depth")
);
if (!response.ok) {
const rawBody = await response.text().catch(() => "");
let body: VoicepeakErrorBody | null = null;
try {
body = rawBody ? (JSON.parse(rawBody) as VoicepeakErrorBody) : null;
} catch {
body = null;
}
const code = body?.error?.code || "UNKNOWN";
const requestId = body?.error?.requestId || headerRequestId;
const retryAfterSeconds = getRetryAfterSeconds(response, body);
const message =
body?.error?.message ||
rawBody ||
`Voicepeak request failed with status ${response.status}`;
const retryable =
response.status === 429 ||
response.status === 503 ||
response.status === 500;
const requestError = new VoicepeakRequestError(
message,
response.status,
code,
retryAfterSeconds,
requestId,
retryable
);
await completeLog({
status: "error",
httpStatus: response.status,
errorCode: code,
requestId,
cacheStatus,
queueWaitMilliseconds,
queueDepth,
durationMilliseconds: Date.now() - startedAt,
error: requestError.message,
});
throw requestError;
}
const bypassWarning =
force && cacheStatus !== "BYPASS"
? `force=true requested but X-Voicepeak-Cache was ${
cacheStatus || "missing"
}`
: undefined;
if (bypassWarning) {
console.warn(bypassWarning);
}
const expectedContentType = format === "wav" ? "audio/wav" : "audio/mpeg";
const contentType = response.headers.get("Content-Type") || "";
if (!contentType.toLowerCase().startsWith(expectedContentType)) {
throw new VoicepeakRequestError(
"Unexpected audio content type: " + (contentType || "missing"),
response.status,
"INVALID_CONTENT_TYPE",
undefined,
headerRequestId
);
}
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength === 0) {
throw new VoicepeakRequestError(
"Voicepeak returned empty audio",
response.status,
"EMPTY_AUDIO",
undefined,
headerRequestId,
true
);
}
await completeLog({
status: "success",
httpStatus: response.status,
requestId: headerRequestId,
cacheStatus,
queueWaitMilliseconds,
queueDepth,
durationMilliseconds: Date.now() - startedAt,
responseBytes: bytes.byteLength,
warning: bypassWarning,
});
return bytes;
} catch (error) {
const requestError =
error instanceof VoicepeakRequestError
? error
: new VoicepeakRequestError(
signal?.aborted
? "Voicepeak request aborted"
: timedOut
? "Voicepeak request timed out"
: error instanceof Error
? error.message
: String(error),
0,
signal?.aborted ? "ABORTED" : timedOut ? "TIMEOUT" : "NETWORK_ERROR",
undefined,
undefined,
!signal?.aborted
);
if (!resultLogged) {
await completeLog({
status: "error",
httpStatus: requestError.status || undefined,
errorCode: requestError.code,
requestId: requestError.requestId,
durationMilliseconds: Date.now() - startedAt,
error: requestError.message,
});
}
throw requestError;
} finally {
clearTimeout(timeout);
signal?.removeEventListener("abort", abortRequest);
}
};
export const requestVoicepeakSpeechBytes = async ({
text: rawText,
settings: _settings,
signal,
debug,
format = "mp3",
force = false,
}: VoicepeakSpeechRequest & {
format?: "mp3" | "wav";
}): Promise<Uint8Array> => {
const text = normalizeVoicepeakSpeechText(rawText);
const codePointCount = countVoicepeakSpeechCodePoints(text);
if (codePointCount === 0 || codePointCount > VOICEPEAK_SPEECH_TEXT_LIMIT) {
throw new VoicepeakRequestError(
`Invalid speech text length: ${codePointCount}`,
0,
"INVALID_TEXT"
);
}
const maximumRetryCount = 3;
for (let attemptNumber = 1; ; attemptNumber++) {
try {
return await requestVoicepeakSpeechBytesOnce({
text,
signal,
debug,
format,
attemptNumber,
force,
});
} catch (error) {
const requestError =
error instanceof VoicepeakRequestError
? error
: new VoicepeakRequestError(String(error), 0, "UNKNOWN");
const retryCount = attemptNumber;
if (
signal?.aborted ||
!requestError.retryable ||
retryCount > maximumRetryCount
) {
throw requestError;
}
const backoffMilliseconds =
requestError.retryAfterSeconds !== undefined
? requestError.retryAfterSeconds * 1000
: 2 ** retryCount * 1000 + Math.floor(Math.random() * 501);
await waitForRetry(backoffMilliseconds, signal);
}
}
};
export const requestVoicepeakSpeech = async (
request: VoicepeakSpeechRequest
): Promise<PreparedVoicepeakAudio> => {
const bytes = await requestVoicepeakSpeechBytes({
...request,
format: "mp3",
});
return createVoicepeakAudioSource(bytes, "mp3");
};
export const requestVoicepeakSpeeches = async (
request: VoicepeakSpeechRequest
): Promise<PreparedVoicepeakAudio[]> => {
const chunks = splitVoicepeakSpeechText(request.text)
.map(normalizeVoicepeakSpeechText)
.filter(
(text) =>
countVoicepeakSpeechCodePoints(text) > 0 &&
countVoicepeakSpeechCodePoints(text) <= VOICEPEAK_SPEECH_TEXT_LIMIT
);
const batchId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const audioBytes: Uint8Array[] = [];
for (let index = 0; index < chunks.length; index++) {
audioBytes.push(
await requestVoicepeakSpeechBytes({
...request,
text: chunks[index],
format: "mp3",
debug: {
batchId,
chunkIndex: index + 1,
totalChunks: chunks.length,
},
})
);
}
return Promise.all(
audioBytes.map((bytes) => createVoicepeakAudioSource(bytes, "mp3"))
);
};
+150
View File
@@ -0,0 +1,150 @@
import type { AudioSource } from "expo-audio";
import { File, Paths } from "expo-file-system";
import { Platform } from "react-native";
export type PreparedVoicepeakAudio =
| {
kind: "expo-audio";
source: AudioSource;
cleanup?: () => void;
}
| {
kind: "native-webview";
html: string;
};
const BASE64_CHARS =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
export const encodeBase64 = (bytes: Uint8Array) => {
let encoded = "";
for (let index = 0; index < bytes.length; index += 3) {
const byte1 = bytes[index] ?? 0;
const byte2 = bytes[index + 1] ?? 0;
const byte3 = bytes[index + 2] ?? 0;
const combined = (byte1 << 16) | (byte2 << 8) | byte3;
encoded += BASE64_CHARS[(combined >> 18) & 0x3f];
encoded += BASE64_CHARS[(combined >> 12) & 0x3f];
encoded +=
index + 1 < bytes.length ? BASE64_CHARS[(combined >> 6) & 0x3f] : "=";
encoded += index + 2 < bytes.length ? BASE64_CHARS[combined & 0x3f] : "=";
}
return encoded;
};
const buildNativeVoicepeakHtml = (
bytes: Uint8Array,
extension: "mp3" | "wav"
) => {
const mimeType = extension === "wav" ? "audio/wav" : "audio/mpeg";
const base64 = encodeBase64(bytes);
const source = `data:${mimeType};base64,${base64}`;
return `<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
/>
<style>
html, body {
margin: 0;
padding: 0;
background: transparent;
}
</style>
</head>
<body>
<audio id="voicepeak" autoplay playsinline src="${source}"></audio>
<script>
(function () {
var audio = document.getElementById("voicepeak");
if (!audio) return;
var start = function () {
var result = audio.play();
if (result && typeof result.catch === "function") {
result.catch(function () {});
}
};
document.addEventListener("DOMContentLoaded", start);
audio.addEventListener("canplay", start);
audio.addEventListener("ended", function () {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage("voicepeak-ended");
}
});
audio.addEventListener("error", function () {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage("voicepeak-error");
}
});
start();
})();
</script>
</body>
</html>`;
};
export const EMPTY_NATIVE_VOICEPEAK_HTML = `<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
</head>
<body></body>
</html>`;
export const createVoicepeakAudioSource = async (
bytes: Uint8Array,
extension: "mp3" | "wav" = "mp3"
): Promise<PreparedVoicepeakAudio> => {
if (Platform.OS === "web") {
const normalizedBytes = new Uint8Array(bytes.byteLength);
normalizedBytes.set(bytes);
const blob = new Blob([normalizedBytes], {
type: extension === "wav" ? "audio/wav" : "audio/mpeg",
});
const objectUrl = URL.createObjectURL(blob);
return {
kind: "expo-audio",
source: { uri: objectUrl },
cleanup: () => {
URL.revokeObjectURL(objectUrl);
},
};
}
if (Platform.OS === "ios") {
// WKWebViewの<audio>はAVAudioSessionを独自に切り替え、再生中の音楽を
// 停止させることがある。iOSでは一時ファイルをexpo-audioで再生し、
// setAudioModeAsyncのduckOthers設定を確実に適用する。
const file = new File(
Paths.cache,
`rikka-voicepeak-${Date.now()}-${Math.random()
.toString(36)
.slice(2)}.${extension}`
);
file.create({ overwrite: true });
file.write(bytes);
return {
kind: "expo-audio",
source: { uri: file.uri },
cleanup: () => {
if (file.exists) {
file.delete();
}
},
};
}
return {
kind: "native-webview",
html: buildNativeVoicepeakHtml(bytes, extension),
};
};
+178
View File
@@ -0,0 +1,178 @@
import { Platform } from "react-native";
import * as Updates from "expo-updates";
import Constants from "expo-constants";
import { AS } from "@/storageControl";
const STORAGE_KEY = "voicepeakDebugLogs";
const RETENTION_MILLISECONDS = 7 * 24 * 60 * 60 * 1000;
const MAX_LOG_ENTRIES = 5000;
export type VoicepeakDebugLogStatus = "attempting" | "success" | "error";
export type VoicepeakDebugLogEntry = {
id: string;
batchId?: string;
createdAt: string;
updatedAt: string;
status: VoicepeakDebugLogStatus;
text: string;
textLength: number;
codePointCount: number;
format: "mp3" | "wav";
baseUrl: string;
chunkIndex?: number;
totalChunks?: number;
attemptNumber?: number;
forceRequested?: boolean;
httpStatus?: number;
errorCode?: string;
requestId?: string;
cacheStatus?: string;
queueWaitMilliseconds?: number;
queueDepth?: number;
durationMilliseconds?: number;
responseBytes?: number;
warning?: string;
error?: string;
platform: string;
platformVersion: string;
appVersion?: string;
runtimeVersion?: string;
};
type NewVoicepeakDebugLog = Pick<
VoicepeakDebugLogEntry,
"text" | "format" | "baseUrl"
> &
Partial<
Pick<
VoicepeakDebugLogEntry,
| "batchId"
| "chunkIndex"
| "totalChunks"
| "attemptNumber"
| "forceRequested"
>
>;
type VoicepeakDebugLogResult = Partial<
Pick<
VoicepeakDebugLogEntry,
| "httpStatus"
| "errorCode"
| "requestId"
| "cacheStatus"
| "queueWaitMilliseconds"
| "queueDepth"
| "durationMilliseconds"
| "responseBytes"
| "warning"
| "error"
>
> & {
status: Exclude<VoicepeakDebugLogStatus, "attempting">;
};
let transactionQueue = Promise.resolve();
const serializeError = (error: unknown) => {
const message =
error instanceof Error ? `${error.name}: ${error.message}` : String(error);
return message.slice(0, 2000);
};
const readLogs = async (): Promise<VoicepeakDebugLogEntry[]> => {
try {
const stored = await AS.getItem(STORAGE_KEY);
return Array.isArray(stored) ? stored : [];
} catch {
return [];
}
};
const pruneLogs = (logs: VoicepeakDebugLogEntry[], now = Date.now()) =>
logs
.filter((log) => {
const timestamp = Date.parse(log.createdAt);
return Number.isFinite(timestamp) && now - timestamp < RETENTION_MILLISECONDS;
})
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, MAX_LOG_ENTRIES);
const runTransaction = <T>(operation: () => Promise<T>): Promise<T> => {
const result = transactionQueue.then(operation, operation);
transactionQueue = result.then(
() => undefined,
() => undefined
);
return result;
};
export const createVoicepeakDebugLog = async (
input: NewVoicepeakDebugLog
) => {
const now = new Date().toISOString();
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const entry: VoicepeakDebugLogEntry = {
id,
batchId: input.batchId,
createdAt: now,
updatedAt: now,
status: "attempting",
text: input.text,
textLength: input.text.length,
codePointCount: Array.from(input.text).length,
format: input.format,
baseUrl: input.baseUrl,
chunkIndex: input.chunkIndex,
totalChunks: input.totalChunks,
attemptNumber: input.attemptNumber,
forceRequested: input.forceRequested,
platform: Platform.OS,
platformVersion: String(Platform.Version),
appVersion: Constants.expoConfig?.version,
runtimeVersion: Updates.runtimeVersion ?? undefined,
};
await runTransaction(async () => {
const logs = pruneLogs([entry, ...(await readLogs())]);
await AS.setItem(STORAGE_KEY, logs);
});
return id;
};
export const completeVoicepeakDebugLog = async (
id: string,
result: VoicepeakDebugLogResult
) => {
await runTransaction(async () => {
const logs = await readLogs();
const updatedAt = new Date().toISOString();
const nextLogs = logs.map((log) =>
log.id === id
? {
...log,
...result,
warning: result.warning
? serializeError(result.warning)
: undefined,
error: result.error ? serializeError(result.error) : undefined,
updatedAt,
}
: log
);
await AS.setItem(STORAGE_KEY, pruneLogs(nextLogs));
});
};
export const getVoicepeakDebugLogs = async () =>
runTransaction(async () => {
const logs = pruneLogs(await readLogs());
await AS.setItem(STORAGE_KEY, logs);
return logs;
});
export const clearVoicepeakDebugLogs = async () =>
runTransaction(async () => {
await AS.removeItem(STORAGE_KEY).catch(() => undefined);
});
+69 -33
View File
@@ -150,6 +150,8 @@ export const injectJavascriptData = ({
const s = document.createElement('style'); const s = document.createElement('style');
s.textContent = '@keyframes _jrs_blink{0%,55%{opacity:1}70%,90%{opacity:0}100%{opacity:1}}' + s.textContent = '@keyframes _jrs_blink{0%,55%{opacity:1}70%,90%{opacity:0}100%{opacity:1}}' +
'@keyframes _jrs_glow{0%,55%{box-shadow:0 0 12px 3px rgba(255,0,0,0.9)}70%,90%{box-shadow:0 0 6px 2px rgba(255,0,0,0.45)}100%{box-shadow:0 0 12px 3px rgba(255,0,0,0.9)}}' '@keyframes _jrs_glow{0%,55%{box-shadow:0 0 12px 3px rgba(255,0,0,0.9)}70%,90%{box-shadow:0 0 6px 2px rgba(255,0,0,0.45)}100%{box-shadow:0 0 12px 3px rgba(255,0,0,0.9)}}'
+ '@keyframes _jrs_type_primary{0%,49.999%{opacity:1}50%,100%{opacity:0}}'
+ '@keyframes _jrs_type_secondary{0%,49.999%{opacity:0}50%,99.999%{opacity:1}100%{opacity:0}}'
+ (_isDark ? ( + (_isDark ? (
'html,body{background-color:#3a3f47!important;}' 'html,body{background-color:#3a3f47!important;}'
+ '#main{background-color:#3a3f47!important;}' + '#main{background-color:#3a3f47!important;}'
@@ -190,6 +192,7 @@ export const injectJavascriptData = ({
// データ変数の宣言 // データ変数の宣言
let stationList = {}; let stationList = {};
let trainDataList = []; let trainDataList = [];
let trainDataListReady = false;
let operationList = []; let operationList = [];
let trainDiagramData2 = {}; let trainDiagramData2 = {};
let probremsData = []; let probremsData = [];
@@ -198,7 +201,7 @@ export const injectJavascriptData = ({
let elesiteData = []; let elesiteData = [];
const useEleSiteSetting = ${useElesite === "true"}; const useEleSiteSetting = ${useElesite === "true"};
// 列車種別設定 (typeColor / borderColor / bgColor / label / isWanman) // 列車種別設定 (typeColor / borderColor / bgColor / label)
const TRAIN_TYPE_CFG = ${JSON.stringify(TRAIN_TYPE_CONFIG)}; const TRAIN_TYPE_CFG = ${JSON.stringify(TRAIN_TYPE_CONFIG)};
// operationList から列番が一致する運行情報を返すヘルパー // operationList から列番が一致する運行情報を返すヘルパー
@@ -257,11 +260,13 @@ export const injectJavascriptData = ({
const DatalistUpdate = () => { const DatalistUpdate = () => {
fetch("${API_ENDPOINTS.TRAIN_DATA_API}").then(r => r.json()) fetch("${API_ENDPOINTS.TRAIN_DATA_API}").then(r => r.json())
.then(data => { .then(data => {
if (hasChanged('trainDataList', data.data)) { const nextTrainDataList = Array.isArray(data?.data) ? data.data : null;
trainDataList = data.data; if (!nextTrainDataList) return;
_wcache.set('trainDataList', trainDataList); trainDataListReady = true;
setReload(); if (!hasChanged('trainDataList', nextTrainDataList)) return;
} trainDataList = nextTrainDataList;
_wcache.set('trainDataList', trainDataList);
setReload();
}) })
.catch(() => {}) .catch(() => {})
.finally(() => { setTimeout(DatalistUpdate, ${INTERVALS.DELAY_UPDATE}); }); .finally(() => { setTimeout(DatalistUpdate, ${INTERVALS.DELAY_UPDATE}); });
@@ -360,8 +365,12 @@ export const injectJavascriptData = ({
Object.keys(_hashes).forEach(k => { _hashes[k] = null; }); Object.keys(_hashes).forEach(k => { _hashes[k] = null; });
Promise.allSettled([ Promise.allSettled([
fetch("${API_ENDPOINTS.TRAIN_DATA_API}").then(r => r.json()).then(data => { fetch("${API_ENDPOINTS.TRAIN_DATA_API}").then(r => r.json()).then(data => {
trainDataList = data.data ?? trainDataList; if (Array.isArray(data?.data)) {
_hashes['trainDataList'] = JSON.stringify(trainDataList); trainDataList = data.data;
trainDataListReady = true;
_hashes['trainDataList'] = JSON.stringify(trainDataList);
_wcache.set('trainDataList', trainDataList);
}
}).catch(() => {}), }).catch(() => {}),
fetch("${API_ENDPOINTS.OPERATION_LOGS}").then(r => r.json()).then(data => { fetch("${API_ENDPOINTS.OPERATION_LOGS}").then(r => r.json()).then(data => {
const source = Array.isArray(data?.data) ? data.data : []; const source = Array.isArray(data?.data) ? data.data : [];
@@ -404,6 +413,7 @@ export const injectJavascriptData = ({
if (_c0.operationList) { operationList = _c0.operationList; _hashes['operationList'] = JSON.stringify(_c0.operationList); anyHit = true; } if (_c0.operationList) { operationList = _c0.operationList; _hashes['operationList'] = JSON.stringify(_c0.operationList); anyHit = true; }
if (_c0.probremsData) { probremsData = _c0.probremsData; _hashes['probremsData'] = JSON.stringify(_c0.probremsData); anyHit = true; } if (_c0.probremsData) { probremsData = _c0.probremsData; _hashes['probremsData'] = JSON.stringify(_c0.probremsData); anyHit = true; }
if (_c0.trainDataList) { trainDataList = _c0.trainDataList; _hashes['trainDataList'] = JSON.stringify(_c0.trainDataList); anyHit = true; } if (_c0.trainDataList) { trainDataList = _c0.trainDataList; _hashes['trainDataList'] = JSON.stringify(_c0.trainDataList); anyHit = true; }
if (_c0.trainDataList) trainDataListReady = true;
if (_c0.trainDiagramData2) { trainDiagramData2 = _c0.trainDiagramData2; _hashes['trainDiagramData2'] = JSON.stringify(_c0.trainDiagramData2); anyHit = true; } if (_c0.trainDiagramData2) { trainDiagramData2 = _c0.trainDiagramData2; _hashes['trainDiagramData2'] = JSON.stringify(_c0.trainDiagramData2); anyHit = true; }
if (_c0.unyohubData) { unyohubData = _c0.unyohubData; anyHit = true; } if (_c0.unyohubData) { unyohubData = _c0.unyohubData; anyHit = true; }
if (_c0.elesiteData) { elesiteData = _c0.elesiteData; anyHit = true; } if (_c0.elesiteData) { elesiteData = _c0.elesiteData; anyHit = true; }
@@ -441,7 +451,9 @@ export const injectJavascriptData = ({
// TRAIN_DATA_API: 0.84s/1MB, DIAGRAM_TODAY: 0.27s/522KB // TRAIN_DATA_API: 0.84s/1MB, DIAGRAM_TODAY: 0.27s/522KB
Promise.allSettled([ Promise.allSettled([
fetch("${API_ENDPOINTS.TRAIN_DATA_API}").then(r => r.json()).then(data => { fetch("${API_ENDPOINTS.TRAIN_DATA_API}").then(r => r.json()).then(data => {
trainDataList = data.data ?? []; if (!Array.isArray(data?.data)) return;
trainDataList = data.data;
trainDataListReady = true;
_hashes['trainDataList'] = JSON.stringify(trainDataList); _hashes['trainDataList'] = JSON.stringify(trainDataList);
_wcache.set('trainDataList', trainDataList); _wcache.set('trainDataList', trainDataList);
}).catch(() => {}), }).catch(() => {}),
@@ -643,13 +655,19 @@ export const injectJavascriptData = ({
let heightData = "22px"; let heightData = "22px";
let widthData = isHubDataset ? "18px" : "auto"; let widthData = isHubDataset ? "18px" : "auto";
let transformData = isHubDataset ? "scale(1.15)" : "none"; let transformData = isHubDataset ? "scale(1.15)" : "none";
let filterData = isHubDataset
? "drop-shadow(0 0 0.8px rgba(255,255,255,0.75)) drop-shadow(0 1px 1px rgba(0,0,0,0.38)) drop-shadow(0 1px 1.5px rgba(0,0,0,0.18))"
: "drop-shadow(0 1px 1.5px rgba(0,0,0,0.35))";
if(backCount == 0){ if(backCount == 0){
marginData = position ? ${uiSetting === "tokyo" ? `"0px 0px -10px 0px" : "-10px 0px 0px 0px"`: `"0px 2px 0px 0px" : "0px 2px 0px 0px"`}; marginData = position ? ${uiSetting === "tokyo" ? `"0px 0px -10px 0px" : "-10px 0px 0px 0px"`: `"0px 2px 0px 0px" : "0px 2px 0px 0px"`};
heightData = "16px"; heightData = "16px";
widthData = isHubDataset ? "13px" : "auto"; widthData = isHubDataset ? "13px" : "auto";
if (isHubDataset) {
filterData = "drop-shadow(0 0 0.6px rgba(255,255,255,0.7)) drop-shadow(0 1px 0.8px rgba(0,0,0,0.36)) drop-shadow(0 1px 1.2px rgba(0,0,0,0.16))";
}
} }
setIconElem.insertAdjacentHTML('beforebegin', "<img src="+img+" style='float:"+(position ? 'left' : 'right')+";height:"+heightData+";width:"+widthData+";object-fit:contain;transform:"+transformData+";transform-origin:center center;margin: "+marginData+";background-color: "+backgroundColor+";'>"); setIconElem.insertAdjacentHTML('beforebegin', "<img src="+img+" style='float:"+(position ? 'left' : 'right')+";height:"+heightData+";width:"+widthData+";object-fit:contain;transform:"+transformData+";transform-origin:center center;filter:"+filterData+";margin: "+marginData+";background-color: "+backgroundColor+";'>");
if (backCount == 0 || backCount == 100) setIconElem.remove(); if (backCount == 0 || backCount == 100) setIconElem.remove();
} }
@@ -677,10 +695,10 @@ export const injectJavascriptData = ({
const normal_train_name = ` const normal_train_name = `
const nameReplace = (,,,hasProblem,isLeft) =>{ const nameReplace = (,,,hasProblem,isLeft) =>{
let isWanman = false;
let trainName = ""; let trainName = "";
let trainType = ""; let trainType = "";
let trainTypeColor = "black"; let trainTypeColor = "black";
let resolvedType = "";
let viaData = ""; let viaData = "";
let ToData = ""; let ToData = "";
let TrainNumber = ; let TrainNumber = ;
@@ -688,6 +706,21 @@ export const injectJavascriptData = ({
let isSeason = false; let isSeason = false;
let TrainNumberOverride; let TrainNumberOverride;
let optionalText = ""; let optionalText = "";
const registeredTrainData = trainDataList.find(e => String(e.train_id) === String());
// 列車マスタに未登録でも在線カード自体は残す。
// 推測した種別・行先等は表示せず、列番と車両アイコンだけの最小表示にする。
if(trainDataListReady && !registeredTrainData){
.innerText = "";
${uiSetting === "tokyo" ? `
.insertAdjacentHTML('beforebegin', "<div data-jrs-train-data='unavailable' style='width:100%;display:flex;flex:1;flex-direction:" + (isLeft ? "column-reverse" : "column") + ";font-weight:bold;-webkit-text-size-adjust:100%;text-size-adjust:100%;'><p style='font-size:6px;padding:0;color:black;text-align:center;'>" + + "</p><div style='flex:1;'></div><p style='font-size:8px;font-weight:bold;padding:0;text-align:center;color:" + (hasProblem ? "red" : "black") + ";" + (hasProblem ? "animation:_jrs_blink 3s linear infinite;" : "") + "'>" + (hasProblem ? "‼️停止中‼️" : "") + "</p></div>");
` : `
var _missingTextColor = _isDark ? 'white' : 'black';
.insertAdjacentHTML('beforebegin', "<p data-jrs-train-data='unavailable' style='font-size:10px;padding:0;color:" + _missingTextColor + ";'>" + + "</p>");
`}
.remove();
return;
}
try{ try{
const diagram = trainDiagramData2[] || trainTimeInfo[]; const diagram = trainDiagramData2[] || trainTimeInfo[];
if(diagram){ if(diagram){
@@ -705,9 +738,6 @@ export const injectJavascriptData = ({
trainName = textBase; trainName = textBase;
} }
if(new RegExp(/^4[1-9]\\d\\d[DM]$/).test() || new RegExp(/^5[1-7]\\d\\d[DM]$/).test() || new RegExp(/^3[2-9]\\d\\d[DM]$/).test(TrainNumber) ){
isWanman = true;
}
if(new RegExp(/^49[0-4]\\dD$/).test() || new RegExp(/^9[0-4]\\dD$/).test()){ if(new RegExp(/^49[0-4]\\dD$/).test() || new RegExp(/^9[0-4]\\dD$/).test()){
viaData = "(海経由)"; viaData = "(海経由)";
} }
@@ -729,7 +759,6 @@ export const injectJavascriptData = ({
case "4430D": case "4430D":
case "4472D": case "4472D":
viaData = "牟岐線直通"; viaData = "牟岐線直通";
isWanman = true;
ToData = "牟岐"; ToData = "牟岐";
break; break;
case "434D": case "434D":
@@ -743,12 +772,10 @@ export const injectJavascriptData = ({
case "4466D": case "4466D":
case "4470D": case "4470D":
viaData = "牟岐線直通"; viaData = "牟岐線直通";
isWanman = true;
ToData = "阿南"; ToData = "阿南";
break; break;
case "4456D": case "4456D":
viaData = "牟岐線直通"; viaData = "牟岐線直通";
isWanman = true;
ToData = "阿波海南" ToData = "阿波海南"
break; break;
//鳴門線発牟岐線行き //鳴門線発牟岐線行き
@@ -768,7 +795,6 @@ export const injectJavascriptData = ({
case "4350D": case "4350D":
case "4368D": case "4368D":
viaData = "高徳線直通"; viaData = "高徳線直通";
isWanman = true;
break; break;
//牟岐線発徳島線行き //牟岐線発徳島線行き
@@ -782,7 +808,6 @@ export const injectJavascriptData = ({
case "5471D": case "5471D":
case "5479D": case "5479D":
viaData = "徳島線直通"; viaData = "徳島線直通";
isWanman = true;
break; break;
//牟岐線発鳴門線行き //牟岐線発鳴門線行き
@@ -792,7 +817,6 @@ export const injectJavascriptData = ({
case "4954D": case "4954D":
case "4978D": case "4978D":
viaData = "鳴門線直通"; viaData = "鳴門線直通";
isWanman = true;
break; break;
//安芸行と併結列車を個別に表示、それ以外をdefaultで下りなら既定の行き先を、上りなら奈半利行を設定 //安芸行と併結列車を個別に表示、それ以外をdefaultで下りなら既定の行き先を、上りなら奈半利行を設定
@@ -839,11 +863,11 @@ export const injectJavascriptData = ({
getThrew(); getThrew();
if(trainDataList.find(e => e.train_id === ) !== undefined){ if(trainDataList.find(e => e.train_id === ) !== undefined){
const data = trainDataList.find(e => e.train_id === ); const data = trainDataList.find(e => e.train_id === );
resolvedType = data.type;
const _tCfg = TRAIN_TYPE_CFG[data.type]; const _tCfg = TRAIN_TYPE_CFG[data.type];
if (_tCfg) { if (_tCfg) {
trainTypeColor = _tCfg.typeColor; trainTypeColor = _tCfg.typeColor;
isWanman = _tCfg.isWanman;
trainType = _tCfg.label; trainType = _tCfg.label;
} }
isEdit = data.priority == 400; isEdit = data.priority == 400;
@@ -868,8 +892,9 @@ export const injectJavascriptData = ({
optionalText = data.optional_text; optionalText = data.optional_text;
} }
} }
const isOneManType = resolvedType === "OneMan" || resolvedType === "OneManRapid";
//列番付与 //列番付与
const returnText1 = (isWanman ? "ワンマン " : "") + trainName + viaData; const returnText1 = trainName + viaData;
.innerText = ""; .innerText = "";
${uiSetting === "tokyo" ? ` ${uiSetting === "tokyo" ? `
let stationIDs = []; let stationIDs = [];
@@ -944,6 +969,18 @@ export const injectJavascriptData = ({
} }
const optionalTextColor = optionalText.includes("最終") ? "red" : "black"; const optionalTextColor = optionalText.includes("最終") ? "red" : "black";
const hasViaData = !!viaData && viaData.trim() !== "";
const hasOptionalText = !!optionalText && optionalText.trim() !== "";
const hasTrainName = !!trainName && trainName.trim() !== "";
const hasToData = !!ToData && ToData.trim() !== "";
const trainTypeTextStyle = "font-size:10px;font-weight:bold;font-style:italic;padding:0;margin:0;color:#ffffff;text-align:center;line-height:1;";
const trainTypeFrameStyle = "position:relative;height:10px;width:100%;overflow:hidden;display:flex;align-items:center;justify-content:center;";
const trainTypeMainStyle = trainTypeTextStyle + "display:flex;align-items:center;justify-content:center;width:100%;height:100%;";
const trainTypeWanmanStyle = trainTypeMainStyle.replace("font-size:10px;", "font-size:9px;");
const trainTypeHtml = isOneManType
? "<div style='" + trainTypeFrameStyle + "'><p style='" + trainTypeMainStyle + "position:absolute;inset:0;animation:_jrs_type_primary 4s linear infinite;'>" + trainType + "</p><p style='" + trainTypeWanmanStyle + "position:absolute;inset:0;animation:_jrs_type_secondary 4s linear infinite;opacity:0;'>ワンマン</p></div>"
: "<div style='" + trainTypeFrameStyle + "'><p style='" + trainTypeMainStyle + "'>" + trainType + "</p></div>";
const trainTypeContainerPadding = isLeft ? "1px 0 2px 0" : "2px 0 1px 0";
const unyohubLookupNum = TrainNumberOverride || ; const unyohubLookupNum = TrainNumberOverride || ;
const unyohubFormation = getUnyohubFormation(unyohubLookupNum); const unyohubFormation = getUnyohubFormation(unyohubLookupNum);
const hasUnyohub = unyohubFormation !== null; const hasUnyohub = unyohubFormation !== null;
@@ -989,12 +1026,17 @@ export const injectJavascriptData = ({
badgeHtml += "<div style='position:absolute;" + badgePosition + ":0;" + offsetStyle + "background-color:#44bb44;border-radius:50%;border:1px solid #228822;width:19px;height:19px;box-sizing:border-box;overflow:hidden;display:flex;align-items:center;justify-content:center;'><img src='https://storage.haruk.in/elesite_logo.jpg' style='width:19px;height:19px;'/></div>"; badgeHtml += "<div style='position:absolute;" + badgePosition + ":0;" + offsetStyle + "background-color:#44bb44;border-radius:50%;border:1px solid #228822;width:19px;height:19px;box-sizing:border-box;overflow:hidden;display:flex;align-items:center;justify-content:center;'><img src='https://storage.haruk.in/elesite_logo.jpg' style='width:19px;height:19px;'/></div>";
} }
.insertAdjacentHTML('beforebegin', "<div style='width:100%;display:flex;flex:1;flex-direction:"+(isLeft ? "column-reverse" : "column") + ";font-weight:bold;'>" + badgeHtml + "<p style='font-size:6px;padding:0;color:black;text-align:center;'>" + (TrainNumberOverride ? TrainNumberOverride : TrainNumber) + "</p><div style='flex:1;'></div><p style='font-size:8px;font-weight:bold;padding:0;color:black;text-align:center;'>" + (isWanman ? "ワンマン " : "") + "</p><p style='font-size:6px;font-weight:bold;padding:0;color:black;text-align:center;border-style:solid;border-width: "+(!!yosan2Color ? "2px" : "0px")+";border-color:" + yosan2Color + "'>" + viaData + "</p><p style='font-size:8px;font-weight:bold;padding:0;color: " + optionalTextColor + ";text-align:center;'>" + optionalText + "</p><p style='font-size:8px;font-weight:bold;padding:0;color:black;text-align:center;'>" + trainName + "</p><div style='width:100%;background:" + gradient + ";'><p style='font-size:10px;font-weight:bold;padding:0;margin:0;color:white;align-items:center;align-content:center;text-align:center;text-shadow:1px 1px 0px #00000030, -1px -1px 0px #00000030,-1px 1px 0px #00000030, 1px -1px 0px #00000030,1px 0px 0px #00000030, -1px 0px 0px #00000030,0px 1px 0px #00000030, 0px -1px 0px #00000030;'>" + (ToData ? ToData + "行" : ToData) + "</p></div><div style='width:100%;background:" + trainTypeColor + ";border-radius:"+(isLeft ? "4px 4px 0 0" : "0 0 4px 4px")+";'><p style='font-size:10px;font-weight:bold;font-style:italic;padding:0;color: white;text-align:center;'>" + trainType + "</p></div><p style='font-size:8px;font-weight:bold;padding:0;text-align:center;color: "+(hasProblem ? "red": "black")+"; "+(hasProblem ? "animation:_jrs_blink 3s linear infinite;" : "")+"'>" + (hasProblem ? "‼️停止中‼️" : "") + "</p></div>"); const viaDataHtml = hasViaData ? "<p style='font-size:6px;font-weight:bold;padding:0;margin:0;color:black;text-align:center;border-style:solid;border-width: " + (!!yosan2Color ? "2px" : "0px") + ";border-color:" + yosan2Color + "'>" + viaData + "</p>" : "";
const optionalTextHtml = hasOptionalText ? "<p style='font-size:8px;font-weight:bold;padding:0;margin:0;color: " + optionalTextColor + ";text-align:center;'>" + optionalText + "</p>" : "";
const trainNameHtml = hasTrainName ? "<p style='font-size:8px;font-weight:bold;padding:0;margin:0;color:black;text-align:center;'>" + trainName + "</p>" : "";
const toDataHtml = hasToData ? "<div style='width:100%;min-height:13px;height:auto;box-sizing:border-box;display:flex;align-items:center;justify-content:center;background:" + gradient + ";padding:1px 0;overflow:visible;'><p style='font-size:10px;font-weight:bold;padding:0;margin:0;color:white;line-height:1.1;text-align:center;text-shadow:1px 1px 0px #00000030, -1px -1px 0px #00000030,-1px 1px 0px #00000030, 1px -1px 0px #00000030,1px 0px 0px #00000030, -1px 0px 0px #00000030,0px 1px 0px #00000030, 0px -1px 0px #00000030;'>" + ToData + "行</p></div>" : "";
const problemHtml = hasProblem ? "<p style='font-size:8px;font-weight:bold;padding:0;margin:0;text-align:center;color:red;animation:_jrs_blink 3s linear infinite;'>‼️停止中‼️</p>" : "";
.insertAdjacentHTML('beforebegin', "<div style='width:100%;display:flex;flex:1;flex-direction:"+(isLeft ? "column-reverse" : "column") + ";font-weight:bold;-webkit-text-size-adjust:100%;text-size-adjust:100%;'>" + badgeHtml + "<p style='font-size:6px;padding:0;margin:0;color:black;text-align:center;'>" + (TrainNumberOverride ? TrainNumberOverride : TrainNumber) + "</p><div style='flex:1;'></div>" + viaDataHtml + optionalTextHtml + trainNameHtml + toDataHtml + "<div style='width:100%;height:13px;box-sizing:border-box;background:" + trainTypeColor + ";border-radius:"+(isLeft ? "4px 4px 0 0" : "0 0 4px 4px")+";display:flex;align-items:center;justify-content:center;padding:" + trainTypeContainerPadding + ";margin:0;overflow:hidden;'>" + trainTypeHtml + "</div>" + problemHtml + "</div>");
`: ` `: `
var _defTextColor = _isDark ? 'white' : 'black'; var _defTextColor = _isDark ? 'white' : 'black';
.insertAdjacentHTML('beforebegin', "<p style='font-size:10px;font-weight:bold;padding:0;color:" + _defTextColor + ";'>" + returnText1 + "</p>"); if(returnText1){ .insertAdjacentHTML('beforebegin', "<p style='font-size:10px;font-weight:bold;padding:0;margin:0;color:" + _defTextColor + ";'>" + returnText1 + "</p>"); }
.insertAdjacentHTML('beforebegin', "<div style='display:inline-flex;flex-direction:row;'><p style='font-size:10px;font-weight: bold;padding:0;color:" + _defTextColor + ";'>" + (ToData ? ToData + "行 " : ToData) + "</p><p style='font-size:10px;padding:0;color:" + _defTextColor + ";'>" + (TrainNumberOverride ? TrainNumberOverride : TrainNumber) + "</p></div>"); .insertAdjacentHTML('beforebegin', "<div style='display:inline-flex;flex-direction:row;'><p style='font-size:10px;font-weight: bold;padding:0;margin:0;color:" + _defTextColor + ";'>" + (ToData ? ToData + "行 " : "") + "</p><p style='font-size:10px;padding:0;margin:0;color:" + _defTextColor + ";'>" + (TrainNumberOverride ? TrainNumberOverride : TrainNumber) + "</p></div>");
.insertAdjacentHTML('beforebegin', "<p style='font-size:10px;font-weight:bold;padding:0;color: "+(hasProblem ? "red": _defTextColor)+"; "+(hasProblem ? "animation:_jrs_blink 3s linear infinite;" : "")+"'>" + (hasProblem ? "‼️停止中‼️" : "") + "</p>"); if(hasProblem){ .insertAdjacentHTML('beforebegin', "<p style='font-size:10px;font-weight:bold;padding:0;margin:0;color:red;animation:_jrs_blink 3s linear infinite;'>‼️停止中‼️</p>"); }
`} `}
} }
`; `;
@@ -1014,13 +1056,7 @@ const setNewTrainItem = (element,hasProblem,type)=>{
element.style.backgroundColor = _bgc; element.style.backgroundColor = _bgc;
}else{ }else{
element.style.backgroundColor = '#ffffffff'; element.style.backgroundColor = '#ffffffff';
if(element.getAttribute('offclick').includes("express")){ element.style.borderColor = _isDark ? '#30363d' : 'black';
element.style.borderColor = '#ff0000ff';
}else if(element.getAttribute('offclick').includes("rapid")){
element.style.borderColor = '#008cffff';
}else{
element.style.borderColor = _isDark ? '#30363d' : 'black';
}
} }
element.style.borderWidth = _isDark ? '1px' : '2px'; element.style.borderWidth = _isDark ? '1px' : '2px';
element.style.borderStyle = 'solid'; element.style.borderStyle = 'solid';
+16 -18
View File
@@ -5,31 +5,29 @@
* - borderColor: setNewTrainItem 使 * - borderColor: setNewTrainItem 使
* - bgColor : setNewTrainItem 使 * - bgColor : setNewTrainItem 使
* - label : 種別ラベル文字列 * - label : 種別ラベル文字列
* - isWanman : ワンマン列車かどうか
*/ */
export interface TrainTypeConfig { export interface TrainTypeConfig {
label: string; label: string;
typeColor: string; typeColor: string;
borderColor: string; borderColor: string;
bgColor: string; bgColor: string;
isWanman: boolean;
} }
export const TRAIN_TYPE_CONFIG: Record<string, TrainTypeConfig> = { export const TRAIN_TYPE_CONFIG: Record<string, TrainTypeConfig> = {
Normal: { label: "普通", typeColor: "black", borderColor: "black", bgColor: "#ffffffcc", isWanman: false }, Normal: { label: "普通", typeColor: "black", borderColor: "black", bgColor: "#ffffffcc" },
OneMan: { label: "普通", typeColor: "black", borderColor: "black", bgColor: "#ffffffcc", isWanman: true }, OneMan: { label: "普通", typeColor: "black", borderColor: "black", bgColor: "#ffffffcc" },
Rapid: { label: "快速", typeColor: "rgba(0, 140, 255, 1)", borderColor: "rgba(0, 140, 255, 1)", bgColor: "#ffffffcc", isWanman: false }, Rapid: { label: "快速", typeColor: "rgba(0, 140, 255, 1)", borderColor: "rgba(0, 140, 255, 1)", bgColor: "#ffffffcc" },
OneManRapid: { label: "快速", typeColor: "rgba(0, 140, 255, 1)", borderColor: "rgba(0, 140, 255, 1)", bgColor: "#ffffffcc", isWanman: true }, OneManRapid: { label: "快速", typeColor: "rgba(0, 140, 255, 1)", borderColor: "rgba(0, 140, 255, 1)", bgColor: "#ffffffcc" },
LTDEXP: { label: "特急", typeColor: "red", borderColor: "red", bgColor: "#ffffffcc", isWanman: false }, LTDEXP: { label: "特急", typeColor: "red", borderColor: "red", bgColor: "#ffffffcc" },
NightLTDEXP: { label: "寝台特急", typeColor: "#d300b0ff", borderColor: "#d300b0ff", bgColor: "#ffffffcc", isWanman: false }, NightLTDEXP: { label: "寝台特急", typeColor: "#d300b0ff", borderColor: "#d300b0ff", bgColor: "#ffffffcc" },
SPCL: { label: "臨時", typeColor: "#008d07ff", borderColor: "#008d07ff", bgColor: "#ffffffcc", isWanman: false }, SPCL: { label: "臨時", typeColor: "#008d07ff", borderColor: "#008d07ff", bgColor: "#ffffffcc" },
SPCL_Normal: { label: "臨時", typeColor: "#008d07ff", borderColor: "#008d07ff", bgColor: "#ffffffcc", isWanman: false }, SPCL_Normal: { label: "臨時", typeColor: "#008d07ff", borderColor: "#008d07ff", bgColor: "#ffffffcc" },
SPCL_Rapid: { label: "臨時快速", typeColor: "rgba(0, 81, 255, 1)", borderColor: "#0051ffff", bgColor: "#ffffffcc", isWanman: false }, SPCL_Rapid: { label: "臨時快速", typeColor: "rgba(0, 81, 255, 1)", borderColor: "#0051ffff", bgColor: "#ffffffcc" },
SPCL_EXP: { label: "臨時特急", typeColor: "#a52e2eff", borderColor: "#a52e2eff", bgColor: "#ffffffcc", isWanman: false }, SPCL_EXP: { label: "臨時特急", typeColor: "#a52e2eff", borderColor: "#a52e2eff", bgColor: "#ffffffcc" },
Party: { label: "団体", typeColor: "#ff7300ff", borderColor: "#ff7300ff", bgColor: "#ffd0a9ff", isWanman: false }, Party: { label: "団体", typeColor: "#ff7300ff", borderColor: "#ff7300ff", bgColor: "#ffd0a9ff" },
Freight: { label: "貨物", typeColor: "#00869ecc", borderColor: "#00869ecc", bgColor: "#c7c7c7cc", isWanman: false }, Freight: { label: "貨物", typeColor: "#00869ecc", borderColor: "#00869ecc", bgColor: "#c7c7c7cc" },
Forwarding: { label: "回送", typeColor: "#727272cc", borderColor: "#727272cc", bgColor: "#c7c7c7cc", isWanman: false }, Forwarding: { label: "回送", typeColor: "#727272cc", borderColor: "#727272cc", bgColor: "#c7c7c7cc" },
Trial: { label: "試運転", typeColor: "#727272cc", borderColor: "#727272cc", bgColor: "#c7c7c7cc", isWanman: false }, Trial: { label: "試運転", typeColor: "#727272cc", borderColor: "#727272cc", bgColor: "#c7c7c7cc" },
Construction: { label: "工事", typeColor: "#727272cc", borderColor: "#727272cc", bgColor: "#c7c7c7cc", isWanman: false }, Construction: { label: "工事", typeColor: "#727272cc", borderColor: "#727272cc", bgColor: "#c7c7c7cc" },
FreightForwarding: { label: "単機回送", typeColor: "#727272cc", borderColor: "#727272cc", bgColor: "#c7c7c7cc", isWanman: false }, FreightForwarding: { label: "単機回送", typeColor: "#727272cc", borderColor: "#727272cc", bgColor: "#c7c7c7cc" },
}; };
+194 -82
View File
@@ -29,6 +29,7 @@ import { lineList_LineWebID } from "@/lib/getStationList";
import { StationProps } from "@/lib/CommonTypes"; import { StationProps } from "@/lib/CommonTypes";
import { LocationObject } from "expo-location"; import { LocationObject } from "expo-location";
import { StationSource } from "@/types"; import { StationSource } from "@/types";
import * as Sentry from "@sentry/react-native";
configureReanimatedLogger({ configureReanimatedLogger({
level: ReanimatedLogLevel.error, // Set the log level to error level: ReanimatedLogLevel.error, // Set the log level to error
strict: true, // Reanimated runs in strict mode by default strict: true, // Reanimated runs in strict mode by default
@@ -43,6 +44,8 @@ type props = {
}; };
const MAP_PIN_SIZE = Platform.OS === "android" ? 32 : 36; const MAP_PIN_SIZE = Platform.OS === "android" ? 32 : 36;
const ANDROID_USER_LOCATION_MARKER_SIZE = 18;
const ANDROID_USER_LOCATION_INNER_SIZE = 8;
export const Menu: FC<props> = (props) => { export const Menu: FC<props> = (props) => {
const { scrollRef, mapHeight, MapFullHeight, mapMode, setMapMode } = props; const { scrollRef, mapHeight, MapFullHeight, mapMode, setMapMode } = props;
@@ -54,12 +57,73 @@ export const Menu: FC<props> = (props) => {
const { favoriteStation } = useFavoriteStation(); const { favoriteStation } = useFavoriteStation();
const { originalStationList, getStationDataFromNameBase } = useStationList(); const { originalStationList, getStationDataFromNameBase } = useStationList();
const [stationSource, _setStationSource] = useState<StationSource>({ type: "position" }); const [stationSource, _setStationSource] = useState<StationSource>({ type: "position" });
const [isHeavyContentReady, setIsHeavyContentReady] = useState(Platform.OS !== "android");
// 時刻表βの遅延レンダリング(画面遷移アニメーション完了後に描画) // 時刻表βの遅延レンダリング(画面遷移アニメーション完了後に描画)
const [ledReady, setLedReady] = useState(false); const [ledReady, setLedReady] = useState(Platform.OS !== "android");
useEffect(() => { useEffect(() => {
const handle = InteractionManager.runAfterInteractions(() => setLedReady(true)); if (Platform.OS !== "android") {
return () => handle.cancel(); setIsHeavyContentReady(true);
}, []); setLedReady(true);
return;
}
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
if (!isMenuFocused) {
if (!isHeavyContentReady) return;
Sentry.addBreadcrumb({
category: "topMenu.runtime",
level: "info",
message: "top menu heavy content preserved on blur",
data: { mapMode },
});
return () => {
cancelled = true;
};
}
if (isHeavyContentReady && ledReady) {
return () => {
cancelled = true;
};
}
Sentry.addBreadcrumb({
category: "topMenu.runtime",
level: "info",
message: "top menu heavy content activation scheduled",
data: { mapMode },
});
const handle = InteractionManager.runAfterInteractions(() => {
timer = setTimeout(() => {
if (cancelled) return;
Sentry.addBreadcrumb({
category: "topMenu.runtime",
level: "info",
message: "top menu heavy content activated",
data: { mapMode },
});
setIsHeavyContentReady(true);
setLedReady(true);
}, 60);
});
return () => {
cancelled = true;
handle.cancel();
if (timer) clearTimeout(timer);
};
}, [isHeavyContentReady, isMenuFocused, ledReady, mapMode]);
useEffect(() => {
Sentry.setContext("top_menu_runtime", {
focused: isMenuFocused,
mapMode,
heavyContentReady: isHeavyContentReady,
ledReady,
});
}, [isHeavyContentReady, isMenuFocused, ledReady, mapMode]);
// 検索モードを閉じたときに戻るソースを記憶 // 検索モードを閉じたときに戻るソースを記憶
const prevNonSearchTypeRef = useRef<"position" | "favorite">("position"); const prevNonSearchTypeRef = useRef<"position" | "favorite">("position");
@@ -258,7 +322,9 @@ export const Menu: FC<props> = (props) => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [listUpStation]); }, [listUpStation]);
useEffect(() => { useEffect(() => {
if (!isHeavyContentReady) return;
if (originalStationList == undefined) return; if (originalStationList == undefined) return;
if (!mapsRef.current) return;
if (listUpStation.length == 0) return; if (listUpStation.length == 0) return;
if (listUpStation[listIndex] == undefined) return; if (listUpStation[listIndex] == undefined) return;
const { lat, lng } = listUpStation[listIndex][0]; const { lat, lng } = listUpStation[listIndex][0];
@@ -279,7 +345,7 @@ export const Menu: FC<props> = (props) => {
} else { } else {
mapsRef.current.animateToRegion(mapRegion, 1000); mapsRef.current.animateToRegion(mapRegion, 1000);
} }
}, [listIndex, listUpStation]); }, [isHeavyContentReady, listIndex, listUpStation]);
return ( return (
<View <View
@@ -313,83 +379,129 @@ export const Menu: FC<props> = (props) => {
} }
}} }}
> >
<MapView {isHeavyContentReady ? (
ref={mapsRef} <MapView
style={{ width: "100%", height: mapMode ? MapFullHeight : mapHeight }} ref={mapsRef}
showsUserLocation={true} style={{ width: "100%", height: mapMode ? MapFullHeight : mapHeight }}
loadingEnabled={true} showsUserLocation={Platform.OS !== "android"}
showsMyLocationButton={false} loadingEnabled={true}
moveOnMarkerPress={false} showsMyLocationButton={false}
showsCompass={false} moveOnMarkerPress={false}
userInterfaceStyle={isDark ? "dark" : "light"} showsCompass={false}
//provider={PROVIDER_GOOGLE} userInterfaceStyle={isDark ? "dark" : "light"}
initialRegion={{ //provider={PROVIDER_GOOGLE}
latitude: 33.774519, initialRegion={{
longitude: 133.533306, latitude: 33.774519,
latitudeDelta: 1.8, //小さくなるほどズーム longitude: 133.533306,
longitudeDelta: 1.8, latitudeDelta: 1.8, //小さくなるほどズーム
}} longitudeDelta: 1.8,
onTouchStart={() => { }}
LayoutAnimation.configureNext({ onTouchStart={() => {
duration: 300, LayoutAnimation.configureNext({
create: { duration: 300,
type: LayoutAnimation.Types.easeInEaseOut, create: {
property: LayoutAnimation.Properties.opacity, type: LayoutAnimation.Types.easeInEaseOut,
}, property: LayoutAnimation.Properties.opacity,
update: { },
type: LayoutAnimation.Types.easeInEaseOut, update: {
property: LayoutAnimation.Properties.opacity, type: LayoutAnimation.Types.easeInEaseOut,
}, property: LayoutAnimation.Properties.opacity,
}); },
setMapMode(true); });
goToMap(); setMapMode(true);
}} goToMap();
> }}
{listUpStation.map(([{ lat, lng, StationNumber }], index) => ( >
<Marker {Platform.OS === "android" && position ? (
key={index + StationNumber} <Marker
coordinate={{ key="android-user-location"
latitude: lat, coordinate={{
longitude: lng, latitude: position.coords.latitude,
}} longitude: position.coords.longitude,
anchor={{ x: 0.5, y: 1 }} }}
tracksViewChanges={false} anchor={{ x: 0.5, y: 0.5 }}
onPress={() => { tracksViewChanges={false}
setMapMode(false); zIndex={999}
setListIndex(index); >
if (mapsRef.current) { <View
mapsRef.current.animateToRegion( style={{
{ width: ANDROID_USER_LOCATION_MARKER_SIZE,
latitude: lat, height: ANDROID_USER_LOCATION_MARKER_SIZE,
longitude: lng, borderRadius: ANDROID_USER_LOCATION_MARKER_SIZE / 2,
latitudeDelta: 0.05, backgroundColor: "rgba(10, 132, 255, 0.24)",
longitudeDelta: 0.05, borderWidth: 1,
borderColor: "rgba(10, 132, 255, 0.42)",
alignItems: "center",
justifyContent: "center",
}}
>
<View
style={{
width: ANDROID_USER_LOCATION_INNER_SIZE,
height: ANDROID_USER_LOCATION_INNER_SIZE,
borderRadius: ANDROID_USER_LOCATION_INNER_SIZE / 2,
backgroundColor: "#0A84FF",
borderWidth: 1,
borderColor: "#ffffff",
}}
/>
</View>
</Marker>
) : null}
{listUpStation.map(([{ lat, lng, StationNumber }], index) => (
<Marker
key={index + StationNumber}
coordinate={{
latitude: lat,
longitude: lng,
}}
anchor={{ x: 0.5, y: 1 }}
tracksViewChanges={false}
onPress={() => {
setMapMode(false);
setListIndex(index);
if (mapsRef.current) {
mapsRef.current.animateToRegion(
{
latitude: lat,
longitude: lng,
latitudeDelta: 0.05,
longitudeDelta: 0.05,
},
1000
);
}
LayoutAnimation.configureNext({
duration: 300,
create: {
type: LayoutAnimation.Types.easeInEaseOut,
property: LayoutAnimation.Properties.opacity,
}, },
1000 update: {
); type: LayoutAnimation.Types.easeInEaseOut,
} property: LayoutAnimation.Properties.opacity,
LayoutAnimation.configureNext({ },
duration: 300, });
create: { returnToTop();
type: LayoutAnimation.Types.easeInEaseOut, }}
property: LayoutAnimation.Properties.opacity, >
}, <Image
update: { source={require("@/assets/reccha-small.png")}
type: LayoutAnimation.Types.easeInEaseOut, style={{ width: MAP_PIN_SIZE, height: MAP_PIN_SIZE }}
property: LayoutAnimation.Properties.opacity, resizeMode="contain"
}, />
}); </Marker>
returnToTop(); ))}
}} </MapView>
> ) : (
<Image <View
source={require("@/assets/reccha-small.png")} style={{
style={{ width: MAP_PIN_SIZE, height: MAP_PIN_SIZE }} width: "100%",
resizeMode="contain" height: mapMode ? MapFullHeight : mapHeight,
/> backgroundColor: colors.background,
</Marker> }}
))} />
</MapView> )}
{!mapMode && ( {!mapMode && (
<CarouselTypeChanger <CarouselTypeChanger
{...{ {...{
@@ -405,7 +517,7 @@ export const Menu: FC<props> = (props) => {
/> />
)} )}
{originalStationList.length != 0 && ( {Object.keys(originalStationList).length !== 0 && isHeavyContentReady && (
<> <>
<CarouselBox <CarouselBox
{...{ {...{
+5 -3
View File
@@ -1,7 +1,9 @@
const { getDefaultConfig } = require("expo/metro-config");
const path = require("path"); const path = require("path");
const {
getSentryExpoConfig
} = require("@sentry/react-native/metro");
const config = getDefaultConfig(__dirname); const config = getSentryExpoConfig(__dirname);
// Ensure local Expo modules in modules/ are watched and resolved directly // Ensure local Expo modules in modules/ are watched and resolved directly
config.watchFolders = [ config.watchFolders = [
@@ -22,4 +24,4 @@ config.transformer = {
experimentalImportSupport: true, experimentalImportSupport: true,
}; };
module.exports = config; module.exports = config;
@@ -16,7 +16,7 @@ Pod::Spec.new do |s|
s.static_framework = true s.static_framework = true
s.dependency 'ExpoModulesCore' s.dependency 'ExpoModulesCore'
s.frameworks = 'ActivityKit' s.frameworks = 'ActivityKit', 'CoreLocation', 'UserNotifications'
s.pod_target_xcconfig = { s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES', 'DEFINES_MODULE' => 'YES',
@@ -196,6 +196,35 @@ public class ExpoLiveActivityModule: Module {
Activity<StationLockAttributes>.activities.map { $0.id } Activity<StationLockAttributes>.activities.map { $0.id }
} }
// MARK:
Function("hasNotificationSound") { (fileName: String) -> Bool in
RikkaLocationAnnouncementManager.hasNotificationSound(fileName: fileName)
}
AsyncFunction("saveNotificationSound") { (fileName: String, base64Data: String, promise: Promise) in
RikkaLocationAnnouncementManager.saveNotificationSound(
fileName: fileName,
base64Data: base64Data,
promise: promise
)
}
AsyncFunction("scheduleLocationAnnouncements") { (trackingId: String, announcements: [LocationAnnouncementRecord], promise: Promise) in
RikkaLocationAnnouncementManager.schedule(
trackingId: trackingId,
announcements: announcements,
promise: promise
)
}
AsyncFunction("cancelLocationAnnouncements") { (trackingId: String, promise: Promise) in
RikkaLocationAnnouncementManager.cancel(
trackingId: trackingId,
promise: promise
)
}
// MARK: End all // MARK: End all
AsyncFunction("endAllActivities") { (promise: Promise) in AsyncFunction("endAllActivities") { (promise: Promise) in
@@ -0,0 +1,193 @@
import CoreLocation
import ExpoModulesCore
import Foundation
import UserNotifications
struct LocationAnnouncementRecord: Record {
@Field var identifier: String = ""
@Field var stationName: String = ""
@Field var latitude: Double = 0
@Field var longitude: Double = 0
@Field var radiusMeters: Double = 800
@Field var soundFileName: String = ""
}
enum RikkaLocationAnnouncementManager {
private static let requestPrefix = "rikka-location-"
private static let maximumScheduledStations = 20
static func hasNotificationSound(fileName: String) -> Bool {
guard let soundURL = notificationSoundURL(fileName: fileName) else {
return false
}
return FileManager.default.fileExists(atPath: soundURL.path)
}
static func saveNotificationSound(
fileName: String,
base64Data: String,
promise: Promise
) {
guard let soundURL = notificationSoundURL(fileName: fileName) else {
promise.reject("ERR_NOTIFICATION_SOUND", "Invalid notification sound file name")
return
}
guard let data = Data(base64Encoded: base64Data) else {
promise.reject("ERR_NOTIFICATION_SOUND", "Invalid base64 audio data")
return
}
do {
try FileManager.default.createDirectory(
at: soundURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try data.write(to: soundURL, options: .atomic)
promise.resolve(nil)
} catch {
promise.reject("ERR_NOTIFICATION_SOUND", error.localizedDescription)
}
}
static func schedule(
trackingId: String,
announcements: [LocationAnnouncementRecord],
promise: Promise
) {
let center = UNUserNotificationCenter.current()
let prefix = identifierPrefix(trackingId: trackingId)
center.getNotificationSettings { settings in
guard settings.authorizationStatus == .authorized ||
settings.authorizationStatus == .provisional else {
promise.reject(
"ERR_NOTIFICATION_PERMISSION",
"Notification permission is required for Rikka announcements"
)
return
}
center.getPendingNotificationRequests { pendingRequests in
let oldIdentifiers = pendingRequests
.map(\.identifier)
.filter { $0.hasPrefix(prefix) }
center.removePendingNotificationRequests(withIdentifiers: oldIdentifiers)
let group = DispatchGroup()
let lock = NSLock()
var scheduledCount = 0
var firstError: Error?
for announcement in announcements.prefix(maximumScheduledStations) {
guard
announcement.latitude.isFinite,
announcement.longitude.isFinite,
(-90.0...90.0).contains(announcement.latitude),
(-180.0...180.0).contains(announcement.longitude),
hasNotificationSound(fileName: announcement.soundFileName)
else { continue }
let content = UNMutableNotificationContent()
content.title = "列車追従・りっかちゃん"
content.body = "次は、\(announcement.stationName)です。"
content.sound = UNNotificationSound(
named: UNNotificationSoundName(rawValue: announcement.soundFileName)
)
content.threadIdentifier = "rikka-train-follow"
content.userInfo = [
"type": "train-follow-announcement",
"stationName": announcement.stationName,
"trackingId": trackingId
]
let coordinate = CLLocationCoordinate2D(
latitude: announcement.latitude,
longitude: announcement.longitude
)
let radius = min(max(announcement.radiusMeters, 200), 2_000)
let identifier = "\(prefix)\(announcement.identifier)"
let region = CLCircularRegion(
center: coordinate,
radius: radius,
identifier: identifier
)
region.notifyOnEntry = true
region.notifyOnExit = false
let trigger = UNLocationNotificationTrigger(region: region, repeats: false)
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: trigger
)
group.enter()
center.add(request) { error in
lock.lock()
if let error, firstError == nil {
firstError = error
} else if error == nil {
scheduledCount += 1
}
lock.unlock()
group.leave()
}
}
group.notify(queue: .main) {
if scheduledCount == 0, let firstError {
promise.reject(
"ERR_LOCATION_ANNOUNCEMENT",
firstError.localizedDescription
)
} else {
promise.resolve(scheduledCount)
}
}
}
}
}
static func cancel(trackingId: String, promise: Promise) {
let center = UNUserNotificationCenter.current()
let prefix = identifierPrefix(trackingId: trackingId)
center.getPendingNotificationRequests { pendingRequests in
let identifiers = pendingRequests
.map(\.identifier)
.filter { $0.hasPrefix(prefix) }
center.removePendingNotificationRequests(withIdentifiers: identifiers)
promise.resolve(nil)
}
}
private static func notificationSoundURL(fileName: String) -> URL? {
let validCharacters = CharacterSet.alphanumerics.union(
CharacterSet(charactersIn: "-_.")
)
let allowedExtensions = ["wav", "caf", "aiff"]
guard
!fileName.isEmpty,
fileName.unicodeScalars.allSatisfy({ validCharacters.contains($0) }),
allowedExtensions.contains((fileName as NSString).pathExtension.lowercased()),
let libraryURL = FileManager.default.urls(
for: .libraryDirectory,
in: .userDomainMask
).first
else { return nil }
return libraryURL
.appendingPathComponent("Sounds", isDirectory: true)
.appendingPathComponent(fileName, isDirectory: false)
}
private static func identifierPrefix(trackingId: String) -> String {
let safeTrackingId = trackingId.replacingOccurrences(
of: "[^A-Za-z0-9_-]",
with: "-",
options: .regularExpression
)
return "\(requestPrefix)\(safeTrackingId)-"
}
}
+43 -3
View File
@@ -122,6 +122,15 @@ export interface StationLockState {
trains?: StationTrainInfo[]; trains?: StationTrainInfo[];
} }
export interface LocationAnnouncement {
identifier: string;
stationName: string;
latitude: number;
longitude: number;
radiusMeters: number;
soundFileName: string;
}
export interface StationTrainInfo { export interface StationTrainInfo {
time: string; time: string;
typeName: string; typeName: string;
@@ -243,11 +252,9 @@ if (ExpoLiveActivityModule) {
* iOS 16.2+ true * iOS 16.2+ true
* Android true * Android true
* *
* NOTE: 一時的に無効化中 false
*/ */
export function isAvailable(): boolean { export function isAvailable(): boolean {
return false; return ExpoLiveActivityModule?.isAvailable() ?? false;
// return ExpoLiveActivityModule?.isAvailable() ?? false;
} }
/** /**
@@ -422,6 +429,39 @@ export function getActiveStationLockActivities(): string[] {
return ExpoLiveActivityModule?.getActiveStationLockActivities() ?? []; return ExpoLiveActivityModule?.getActiveStationLockActivities() ?? [];
} }
// MARK: - りっかちゃん駅接近アナウンス
export function hasNotificationSound(fileName: string): boolean {
if (Platform.OS !== 'ios') return false;
return ExpoLiveActivityModule?.hasNotificationSound(fileName) ?? false;
}
export async function saveNotificationSound(
fileName: string,
base64Data: string
): Promise<void> {
if (Platform.OS !== 'ios' || !ExpoLiveActivityModule) return;
await ExpoLiveActivityModule.saveNotificationSound(fileName, base64Data);
}
export async function scheduleLocationAnnouncements(
trackingId: string,
announcements: LocationAnnouncement[]
): Promise<number> {
if (Platform.OS !== 'ios' || !ExpoLiveActivityModule) return 0;
return await ExpoLiveActivityModule.scheduleLocationAnnouncements(
trackingId,
announcements
);
}
export async function cancelLocationAnnouncements(
trackingId: string
): Promise<void> {
if (Platform.OS !== 'ios' || !ExpoLiveActivityModule) return;
await ExpoLiveActivityModule.cancelLocationAnnouncements(trackingId);
}
// MARK: - Utility // MARK: - Utility
/** /**
+1743 -147
View File
File diff suppressed because it is too large Load Diff
+18 -16
View File
@@ -6,6 +6,7 @@
"android": "expo run:android", "android": "expo run:android",
"ios": "expo run:ios", "ios": "expo run:ios",
"eject": "expo eject", "eject": "expo eject",
"postinstall": "patch-package",
"pushWeb": "npx expo export -p web && netlify deploy --dir dist --prod", "pushWeb": "npx expo export -p web && netlify deploy --dir dist --prod",
"checkDiagram": "bash ./check.sh" "checkDiagram": "bash ./check.sh"
}, },
@@ -22,9 +23,15 @@
"react-native-responsive-screen", "react-native-responsive-screen",
"typescript", "typescript",
"react", "react",
"react-dom" "react-dom",
"expo-live-activity",
"@react-native-vector-icons/fontawesome",
"@react-native-vector-icons/material-icons"
] ]
} }
},
"autolinking": {
"nativeModulesDir": "./modules"
} }
}, },
"dependencies": { "dependencies": {
@@ -43,6 +50,7 @@
"@react-navigation/stack": "^7.8.6", "@react-navigation/stack": "^7.8.6",
"@rneui/base": "5.0.0", "@rneui/base": "5.0.0",
"@rneui/themed": "5.0.0", "@rneui/themed": "5.0.0",
"@sentry/react-native": "~7.11.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"expo": "^55.0.8", "expo": "^55.0.8",
"expo-alternate-app-icons": "8.0.0", "expo-alternate-app-icons": "8.0.0",
@@ -51,9 +59,10 @@
"expo-build-properties": "~55.0.10", "expo-build-properties": "~55.0.10",
"expo-clipboard": "~55.0.9", "expo-clipboard": "~55.0.9",
"expo-constants": "~55.0.9", "expo-constants": "~55.0.9",
"expo-dev-client": "~55.0.18", "expo-dev-client": "~55.0.36",
"expo-device": "~55.0.10", "expo-device": "~55.0.10",
"expo-felica-reader": "file:./modules/expo-felica-reader", "expo-document-picker": "~55.0.14",
"expo-file-system": "~55.0.23",
"expo-font": "~55.0.4", "expo-font": "~55.0.4",
"expo-haptics": "~55.0.9", "expo-haptics": "~55.0.9",
"expo-intent-launcher": "~55.0.9", "expo-intent-launcher": "~55.0.9",
@@ -73,7 +82,7 @@
"lottie-react-native": "~7.3.1", "lottie-react-native": "~7.3.1",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-native": "0.83.2", "react-native": "0.83.6",
"react-native-actions-sheet": "10.1.2", "react-native-actions-sheet": "10.1.2",
"react-native-android-widget": "^0.20.1", "react-native-android-widget": "^0.20.1",
"react-native-gesture-handler": "~2.30.0", "react-native-gesture-handler": "~2.30.0",
@@ -83,6 +92,7 @@
"react-native-responsive-screen": "^1.4.2", "react-native-responsive-screen": "^1.4.2",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.23.0", "react-native-screens": "~4.23.0",
"react-native-share": "^12.3.1",
"react-native-sortables": "1.9.4", "react-native-sortables": "1.9.4",
"react-native-storage": "^1.0.1", "react-native-storage": "^1.0.1",
"react-native-svg": "15.15.3", "react-native-svg": "15.15.3",
@@ -91,22 +101,14 @@
"react-native-view-shot": "~4.0.3", "react-native-view-shot": "~4.0.3",
"react-native-web": "^0.21.2", "react-native-web": "^0.21.2",
"react-native-webview": "13.16.0", "react-native-webview": "13.16.0",
"react-native-worklets": "0.7.2", "react-native-worklets": "0.7.4",
"typescript": "~5.9.3", "typescript": "~5.9.3"
"expo-live-activity": "file:./modules/expo-live-activity"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.2.14", "@types/react": "~19.2.14",
"babel-preset-expo": "~55.0.12", "babel-preset-expo": "~55.0.12",
"baseline-browser-mapping": "^2.10.9" "baseline-browser-mapping": "^2.10.9",
}, "patch-package": "^8.0.1"
"resolutions": {
"react": "19.2.0",
"react-dom": "19.2.0",
"expo-asset": "55.0.10",
"expo-constants": "55.0.9",
"expo-manifests": "55.0.11",
"react-native-worklets": "0.7.2"
}, },
"private": true, "private": true,
"name": "jrshikoku", "name": "jrshikoku",
+118
View File
@@ -0,0 +1,118 @@
{
"main": "index.js",
"scripts": {
"compile-web-script": "npx tsx scripts/compile-web-script.ts",
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"eject": "expo eject",
"pushWeb": "npx expo export -p web && netlify deploy --dir dist --prod",
"checkDiagram": "bash ./check.sh"
},
"expo": {
"doctor": {
"reactNativeDirectoryCheck": {
"exclude": [
"@rneui/base",
"@rneui/themed",
"@lottiefiles/dotlottie-react",
"expo-felica-reader",
"react-native-touchable-scale",
"react-native-vector-icons",
"react-native-responsive-screen",
"typescript",
"react",
"react-dom"
]
}
}
},
"dependencies": {
"@bacons/apple-targets": "^4.0.6",
"@expo/metro-runtime": "~55.0.6",
"@expo/ngrok": "^4.1.3",
"@expo/vector-icons": "^15.1.1",
"@gorhom/bottom-sheet": "^5",
"@lottiefiles/dotlottie-react": "^0.18.7",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "0.3.2",
"@react-native-vector-icons/fontawesome": "^12.4.3",
"@react-native-vector-icons/material-icons": "^12.4.3",
"@react-navigation/bottom-tabs": "^7.15.6",
"@react-navigation/native": "^7.1.34",
"@react-navigation/stack": "^7.8.6",
"@rneui/base": "5.0.0",
"@rneui/themed": "5.0.0",
"@sentry/react-native": "^8.16.0",
"dayjs": "^1.11.20",
"expo": "^55.0.8",
"expo-alternate-app-icons": "8.0.0",
"expo-asset": "~55.0.10",
"expo-audio": "~55.0.9",
"expo-build-properties": "~55.0.10",
"expo-clipboard": "~55.0.9",
"expo-constants": "~55.0.9",
"expo-dev-client": "~55.0.18",
"expo-device": "~55.0.10",
"expo-document-picker": "~55.0.14",
"expo-felica-reader": "file:./modules/expo-felica-reader",
"expo-file-system": "~55.0.23",
"expo-font": "~55.0.4",
"expo-haptics": "~55.0.9",
"expo-intent-launcher": "~55.0.9",
"expo-keep-awake": "~55.0.4",
"expo-linear-gradient": "~55.0.9",
"expo-linking": "~55.0.8",
"expo-live-activity": "file:./modules/expo-live-activity",
"expo-localization": "~55.0.9",
"expo-location": "~55.1.4",
"expo-notifications": "~55.0.13",
"expo-screen-orientation": "~55.0.9",
"expo-sharing": "~55.0.14",
"expo-status-bar": "~55.0.4",
"expo-system-ui": "~55.0.10",
"expo-updates": "~55.0.15",
"expo-video": "~55.0.11",
"expo-web-browser": "~55.0.10",
"lottie-react-native": "~7.3.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.2",
"react-native-actions-sheet": "10.1.2",
"react-native-android-widget": "^0.20.1",
"react-native-gesture-handler": "~2.30.0",
"react-native-maps": "1.27.2",
"react-native-reanimated": "4.2.1",
"react-native-reanimated-carousel": "^4.0.3",
"react-native-responsive-screen": "^1.4.2",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.23.0",
"react-native-share": "^12.3.1",
"react-native-sortables": "1.9.4",
"react-native-storage": "^1.0.1",
"react-native-svg": "15.15.3",
"react-native-touchable-scale": "^2.2.0",
"react-native-vector-icons": "^10.3.0",
"react-native-view-shot": "~4.0.3",
"react-native-web": "^0.21.2",
"react-native-webview": "13.16.0",
"react-native-worklets": "0.7.2",
"typescript": "~5.9.3"
},
"devDependencies": {
"@types/react": "~19.2.14",
"babel-preset-expo": "~55.0.12",
"baseline-browser-mapping": "^2.10.9"
},
"resolutions": {
"react": "19.2.0",
"react-dom": "19.2.0",
"expo-asset": "55.0.10",
"expo-constants": "55.0.9",
"expo-manifests": "55.0.11",
"react-native-worklets": "0.7.2"
},
"private": true,
"name": "jrshikoku",
"version": "1.0.0"
}
@@ -0,0 +1,18 @@
diff --git a/node_modules/@react-navigation/bottom-tabs/src/views/BottomTabView.tsx b/node_modules/@react-navigation/bottom-tabs/src/views/BottomTabView.tsx
index ec0491e..c546b88 100644
--- a/node_modules/@react-navigation/bottom-tabs/src/views/BottomTabView.tsx
+++ b/node_modules/@react-navigation/bottom-tabs/src/views/BottomTabView.tsx
@@ -318,7 +318,12 @@ export function BottomTabView(props: Props) {
return (
<MaybeScreen
key={route.key}
- style={[StyleSheet.absoluteFill, { zIndex: isFocused ? 0 : -1 }]}
+ style={[
+ StyleSheet.absoluteFill,
+ Platform.OS === 'android'
+ ? { opacity: isFocused ? 1 : 0 }
+ : { zIndex: isFocused ? 0 : -1 },
+ ]}
active={activityState}
enabled={detachInactiveScreens}
freezeOnBlur={freezeOnBlur}
+36 -20
View File
@@ -15,6 +15,7 @@ import {
getDiagramTodayUrl, getDiagramTodayUrl,
BACKEND_API_BASE_URLS, BACKEND_API_BASE_URLS,
} from "@/lib/jrDataSystemEnvironment"; } from "@/lib/jrDataSystemEnvironment";
import { observedFetchJson } from "@/lib/observability/network/observedFetch";
const initialState = { const initialState = {
allTrainDiagram: {}, allTrainDiagram: {},
setAllTrainDiagram: (e) => {}, setAllTrainDiagram: (e) => {},
@@ -51,10 +52,10 @@ export const AllTrainDiagramProvider: FC<Props> = ({ children }) => {
setKeyList(Object.keys(allTrainDiagram)); setKeyList(Object.keys(allTrainDiagram));
else setKeyList([]); else setKeyList([]);
}, [allTrainDiagram]); }, [allTrainDiagram]);
const [backendApiBaseUrl, setBackendApiBaseUrl] = React.useState( const [backendApiBaseUrl, setBackendApiBaseUrl] = React.useState<string>(
BACKEND_API_BASE_URLS.production, BACKEND_API_BASE_URLS.production,
); );
const [diagramTodayUrl, setDiagramTodayUrl] = React.useState( const [diagramTodayUrl, setDiagramTodayUrl] = React.useState<string>(
"https://jr-shikoku-api-data-storage.haruk.in/tmp/diagram-today.json", "https://jr-shikoku-api-data-storage.haruk.in/tmp/diagram-today.json",
); );
@@ -68,10 +69,16 @@ export const AllTrainDiagramProvider: FC<Props> = ({ children }) => {
}, []); }, []);
const getTrainDiagram = () => { const getTrainDiagram = () => {
const controller = new AbortController(); observedFetchJson<any[]>(diagramTodayUrl, {
const timeoutId = setTimeout(() => controller.abort(), 15000); endpoint: "timetable_today",
fetch(diagramTodayUrl, { signal: controller.signal }) source: "static_storage",
.then((res) => res.json()) userVisible: false,
preload: true,
fetchPriority: "medium",
timeoutMs: 15000,
retry: true,
urlPathTemplate: "/tmp/diagram-today.json",
})
.then((res) => { .then((res) => {
const data = {}; const data = {};
res.forEach((d) => { res.forEach((d) => {
@@ -101,8 +108,7 @@ export const AllTrainDiagramProvider: FC<Props> = ({ children }) => {
.catch(() => { .catch(() => {
alert("初回の路線情報の取得に失敗しました。"); alert("初回の路線情報の取得に失敗しました。");
}); });
}) });
.finally(() => clearTimeout(timeoutId));
}; };
useEffect(() => { useEffect(() => {
@@ -111,15 +117,20 @@ export const AllTrainDiagramProvider: FC<Props> = ({ children }) => {
useInterval(getTrainDiagram, 30000, true, true); //30秒毎に全在線列車取得(バックグラウンドでも継続) useInterval(getTrainDiagram, 30000, true, true); //30秒毎に全在線列車取得(バックグラウンドでも継続)
const getCustomTrainData = () => { const getCustomTrainData = () => {
const controller = new AbortController(); observedFetchJson<{ data: CustomTrainData[] }>(`${backendApiBaseUrl}/train-data`, {
const timeoutId = setTimeout(() => controller.abort(), 15000); endpoint: "train_operation_data",
fetch(`${backendApiBaseUrl}/train-data`, { signal: controller.signal }) source: "backend_api",
.then((res) => res.json()) userVisible: false,
preload: true,
fetchPriority: "medium",
timeoutMs: 15000,
retry: true,
urlPathTemplate: "/train-data",
})
.then((res) => { .then((res) => {
setAllCustomTrainData(res.data); setAllCustomTrainData(res.data);
}) })
.catch(() => {}) .catch(() => {});
.finally(() => clearTimeout(timeoutId));
}; };
useEffect(() => { useEffect(() => {
@@ -130,10 +141,16 @@ export const AllTrainDiagramProvider: FC<Props> = ({ children }) => {
const [todayOperation, setTodayOperation] = useState<OperationLogs[]>([]); // 本日の運行情報 const [todayOperation, setTodayOperation] = useState<OperationLogs[]>([]); // 本日の運行情報
const getTodayOperation = () => { const getTodayOperation = () => {
const controller = new AbortController(); observedFetchJson<{ data: OperationLogs[] | null }>(`${backendApiBaseUrl}/operation-logs`, {
const timeoutId = setTimeout(() => controller.abort(), 15000); endpoint: "operation_logs",
fetch(`${backendApiBaseUrl}/operation-logs`, { signal: controller.signal }) source: "backend_api",
.then((res) => res.json()) userVisible: false,
preload: true,
fetchPriority: "medium",
timeoutMs: 15000,
retry: true,
urlPathTemplate: "/operation-logs",
})
.then((res) => { .then((res) => {
if (res.data === null) { if (res.data === null) {
setTodayOperation([]); setTodayOperation([]);
@@ -141,8 +158,7 @@ export const AllTrainDiagramProvider: FC<Props> = ({ children }) => {
} }
setTodayOperation(res.data); setTodayOperation(res.data);
}) })
.catch(() => {}) .catch(() => {});
.finally(() => clearTimeout(timeoutId));
}; };
useEffect(() => { useEffect(() => {
+77 -21
View File
@@ -3,9 +3,12 @@ import React, {
useContext, useContext,
useState, useState,
useEffect, useEffect,
useRef,
FC, FC,
} from "react"; } from "react";
import { InteractionManager } from "react-native";
import useInterval from "../lib/useInterval"; import useInterval from "../lib/useInterval";
import { observedFetchJson, observedFetchText } from "@/lib/observability/network/observedFetch";
const setoStationID = [ const setoStationID = [
"Y00", "Y00",
@@ -361,41 +364,94 @@ export const AreaInfoProvider: FC<props> = ({ children }) => {
const [areaIconBadgeText, setAreaIconBadgeText] = useState(""); const [areaIconBadgeText, setAreaIconBadgeText] = useState("");
const [areaStationID, setAreaStationID] = useState([]); const [areaStationID, setAreaStationID] = useState([]);
const [isInfo, setIsInfo] = useState(false); const [isInfo, setIsInfo] = useState(false);
const getAreaData = () => { const areaDescriptionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
fetch( const initialFetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
"https://script.google.com/macros/s/AKfycbz80LcaEUrhnlEsLkJy0LG2IRO3DBVQhfNmN1d_0f_HvtsujNQpxM90SrV9yKWH_JG1Ww/exec" const fetchAreaDescription = () => {
observedFetchText(
"https://script.google.com/macros/s/AKfycbz80LcaEUrhnlEsLkJy0LG2IRO3DBVQhfNmN1d_0f_HvtsujNQpxM90SrV9yKWH_JG1Ww/exec",
{
endpoint: "operation_info_text",
source: "gas",
userVisible: true,
preload: false,
fetchPriority: "medium",
expectedContentType: "text",
timeoutMs: 15000,
retry: false,
urlPathTemplate: "/macros/s/AKfy.../exec",
}
) )
.then((d) => d.text()) .then((d) => setAreaInfo(d))
.then((d) => setAreaInfo(d)); .catch(() => {});
fetch("https://n8n.haruk.in/webhook/jr-shikoku-trainfo-flag") };
.then((d) => d.json())
const scheduleAreaDescriptionFetch = () => {
if (areaDescriptionTimeoutRef.current) {
clearTimeout(areaDescriptionTimeoutRef.current);
}
areaDescriptionTimeoutRef.current = setTimeout(() => {
areaDescriptionTimeoutRef.current = null;
fetchAreaDescription();
}, 800);
};
const getAreaData = () => {
observedFetchJson<any>("https://n8n.haruk.in/webhook/jr-shikoku-trainfo-flag", {
endpoint: "operation_info_flag",
source: "n8n",
userVisible: true,
preload: true,
fetchPriority: "medium",
timeoutMs: 10000,
retry: true,
urlPathTemplate: "/webhook/jr-shikoku-trainfo-flag",
})
.then((d) => { .then((d) => {
if (!d.data) return; if (!d.data) return;
const lineInfo = d.data.filter((e) => e.area != "genelic"); const lineInfo = d.data.filter((e) => e.area != "genelic");
const genelicInfo = d.data.filter((e) => e.area == "genelic"); const genelicInfo = d.data.filter((e) => e.area == "genelic");
const text = lineInfo const activeLineInfo = lineInfo.filter((e) => e.status);
.filter((e) => e.status) const text = activeLineInfo.map((e) => {
.map((e) => { return `${areaStationPair[e.area].id}`;
return `${areaStationPair[e.area].id}`; });
});
let stationIDList = []; let stationIDList = [];
lineInfo activeLineInfo.forEach((e) => {
.filter((e) => e.status) stationIDList = stationIDList.concat(
.forEach((e) => { areaStationPair[e.area].stationID
stationIDList = stationIDList.concat( );
areaStationPair[e.area].stationID });
);
});
const info = genelicInfo[0].status.includes("nodelay") ? true : false; const info = genelicInfo[0].status.includes("nodelay") ? true : false;
setIsInfo(info); setIsInfo(info);
setAreaStationID(stationIDList); setAreaStationID(stationIDList);
setAreaIconBadgeText( setAreaIconBadgeText(
text.length == 0 ? (info ? "i" : "!") : text.join(",") text.length == 0 ? (info ? "i" : "!") : text.join(",")
); );
}); if (stationIDList.length > 0) {
scheduleAreaDescriptionFetch();
} else {
setAreaInfo("");
}
})
.catch(() => {});
}; };
useEffect(() => { useEffect(() => {
getAreaData(); const task = InteractionManager.runAfterInteractions(() => {
initialFetchTimeoutRef.current = setTimeout(() => {
initialFetchTimeoutRef.current = null;
getAreaData();
}, 1200);
});
return () => {
task.cancel?.();
if (initialFetchTimeoutRef.current) {
clearTimeout(initialFetchTimeoutRef.current);
initialFetchTimeoutRef.current = null;
}
if (areaDescriptionTimeoutRef.current) {
clearTimeout(areaDescriptionTimeoutRef.current);
areaDescriptionTimeoutRef.current = null;
}
};
}, []); }, []);
useInterval(getAreaData, 60000); //60秒毎に全在線列車取得 useInterval(getAreaData, 60000); //60秒毎に全在線列車取得
return ( return (
+28 -17
View File
@@ -19,6 +19,7 @@ import { MOCK_TRAIN_POSITIONS } from "@/lib/mockApi";
import { fetchMockTrainPositions } from "@/lib/mockApi/positionMasters"; import { fetchMockTrainPositions } from "@/lib/mockApi/positionMasters";
import WebView from "react-native-webview"; import WebView from "react-native-webview";
import { StationProps } from "@/lib/CommonTypes"; import { StationProps } from "@/lib/CommonTypes";
import { observedFetchJson } from "@/lib/observability/network/observedFetch";
type loading = "loading" | "success" | "error"; type loading = "loading" | "success" | "error";
const initialState = { const initialState = {
webview: undefined, webview: undefined,
@@ -129,7 +130,7 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
let nearest: StationProps | null = null; let nearest: StationProps | null = null;
let nearestDist = Infinity; let nearestDist = Infinity;
Object.keys(originalStationList).forEach((lineName) => { Object.keys(originalStationList).forEach((lineName) => {
(originalStationList as any)[lineName]?.forEach((station: StationProps) => { originalStationList[lineName]?.forEach((station) => {
const d = _calcDistance({ lat: station.lat, lng: station.lng }, userPos); const d = _calcDistance({ lat: station.lat, lng: station.lng }, userPos);
if (d < nearestDist) { if (d < nearestDist) {
nearestDist = d; nearestDist = d;
@@ -305,15 +306,21 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
setCurrentTrainLoading("success"); setCurrentTrainLoading("success");
}) })
.catch(() => { .catch(() => {
setCurrentTrainLoading("error"); setCurrentTrainLoading(currentTrain.length > 0 ? "success" : "error");
}) })
.finally(() => clearTimeout(timeoutId)); .finally(() => clearTimeout(timeoutId));
return; return;
} }
const controller = new AbortController(); observedFetchJson<any>("https://n8n.haruk.in/webhook/c501550c-7d1b-4e50-927b-4429fe18931a", {
const timeoutId = setTimeout(() => controller.abort(), 8000); endpoint: "positions_current",
fetch("https://n8n.haruk.in/webhook/c501550c-7d1b-4e50-927b-4429fe18931a", { signal: controller.signal }) source: "n8n",
.then((response) => response.json()) userVisible: true,
preload: false,
fetchPriority: "high",
timeoutMs: 8000,
retry: true,
urlPathTemplate: "/webhook/c501550c-7d1b-4e50-927b-4429fe18931a",
})
.then((d) => d.data) .then((d) => d.data)
.then((d) => .then((d) =>
d.map((x) => ({ d.map((x) => ({
@@ -343,13 +350,20 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
}))); })));
}) })
.catch(() => { .catch(() => {
const fallbackController = new AbortController(); observedFetchJson<any[]>(
const fallbackTimeoutId = setTimeout(() => fallbackController.abort(), 8000);
fetch(
"https://script.google.com/macros/s/AKfycby9Y2-Bm75J_WkbZimi7iS8v5r9wMa9wtzpdwES9sOGF4i6HIYEJOM60W6gM1gXzt1o/exec", "https://script.google.com/macros/s/AKfycby9Y2-Bm75J_WkbZimi7iS8v5r9wMa9wtzpdwES9sOGF4i6HIYEJOM60W6gM1gXzt1o/exec",
{ ...HeaderConfig, signal: fallbackController.signal } {
...HeaderConfig,
endpoint: "positions_gas_fallback",
source: "gas",
userVisible: true,
preload: false,
fetchPriority: "high",
timeoutMs: 8000,
retry: true,
urlPathTemplate: "/macros/s/AKfyc.../exec",
}
) )
.then((response) => response.json())
.then((d) => .then((d) =>
d.map((x) => ({ num: x.TrainNum, delay: x.delay, Pos: x.Pos })) d.map((x) => ({ num: x.TrainNum, delay: x.delay, Pos: x.Pos }))
) )
@@ -367,12 +381,9 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
setCurrentTrainLoading("success"); setCurrentTrainLoading("success");
}) })
.catch(() => { .catch(() => {
// エラー時の処理 setCurrentTrainLoading(currentTrain.length > 0 ? "success" : "error");
setCurrentTrainLoading("error"); });
}) });
.finally(() => clearTimeout(fallbackTimeoutId));
})
.finally(() => clearTimeout(timeoutId));
}; };
const inject = (i) => { const inject = (i) => {
webview.current?.injectJavaScript(i); webview.current?.injectJavaScript(i);
+17 -6
View File
@@ -11,7 +11,12 @@ import * as Notifications from "expo-notifications";
import * as Device from "expo-device"; import * as Device from "expo-device";
import Constants from "expo-constants"; import Constants from "expo-constants";
import { logger } from "@/utils/logger"; import { logger } from "@/utils/logger";
import { rootNavigationRef, stackAwareNavigate } from "@/lib/rootNavigation"; import {
markStartupExplicitTarget,
resolveStartupNavigationSource,
rootNavigationRef,
stackAwareNavigate,
} from "@/lib/rootNavigation";
import { SheetManager } from "react-native-actions-sheet"; import { SheetManager } from "react-native-actions-sheet";
import { AS } from "@/storageControl"; import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants"; import { STORAGE_KEYS } from "@/constants";
@@ -209,6 +214,8 @@ export const NotificationProvider: FC<Props> = ({ children }) => {
const action = resolveNotificationRoute(response); const action = resolveNotificationRoute(response);
if (!action) return; if (!action) return;
markStartupExplicitTarget();
if (!rootNavigationRef.isReady()) { if (!rootNavigationRef.isReady()) {
if (retryCount < 8) { if (retryCount < 8) {
setTimeout(() => routeFromNotification(response, retryCount + 1), 300); setTimeout(() => routeFromNotification(response, retryCount + 1), 300);
@@ -263,11 +270,15 @@ export const NotificationProvider: FC<Props> = ({ children }) => {
// キーが存在しない場合は初回起動なので何もしない // キーが存在しない場合は初回起動なので何もしない
}) })
.finally(() => { .finally(() => {
Notifications.getLastNotificationResponseAsync().then((response) => { Notifications.getLastNotificationResponseAsync()
if (response) { .then((response) => {
routeFromNotification(response); if (response) {
} routeFromNotification(response);
}); }
})
.finally(() => {
resolveStartupNavigationSource();
});
}); });
return () => { return () => {
+10 -6
View File
@@ -10,11 +10,13 @@ import {
getStationList, getStationList,
lineList_LineWebID, lineList_LineWebID,
} from "@/lib/getStationList"; } from "@/lib/getStationList";
import { StationProps } from "@/lib/CommonTypes"; import { OriginalStationList, StationProps } from "@/lib/CommonTypes";
type initialStateType = { type initialStateType = {
originalStationList: StationProps[][]; originalStationList: OriginalStationList;
setOriginalStationList: React.Dispatch<React.SetStateAction<StationProps[][]>>; setOriginalStationList: React.Dispatch<
React.SetStateAction<OriginalStationList>
>;
getStationDataFromName: (id: string) => StationProps[]; getStationDataFromName: (id: string) => StationProps[];
getStationDataFromId: (id: string) => StationProps[]; getStationDataFromId: (id: string) => StationProps[];
getStationDataFromNameBase: (name: string) => StationProps[]; getStationDataFromNameBase: (name: string) => StationProps[];
@@ -27,7 +29,7 @@ type initialStateType = {
}; };
const initialState = { const initialState = {
originalStationList: [[]], originalStationList: {},
setOriginalStationList: () => {}, setOriginalStationList: () => {},
getStationDataFromName: () => [], getStationDataFromName: () => [],
getStationDataFromId: () => [], getStationDataFromId: () => [],
@@ -51,7 +53,8 @@ export const useStationList = () => {
}; };
export const StationListProvider: FC<Props> = ({ children }) => { export const StationListProvider: FC<Props> = ({ children }) => {
const [originalStationList, setOriginalStationList] = useState<StationProps[][]>([]); const [originalStationList, setOriginalStationList] =
useState<OriginalStationList>({});
useEffect(() => { useEffect(() => {
getStationList().then(setOriginalStationList); getStationList().then(setOriginalStationList);
}, []); }, []);
@@ -105,9 +108,10 @@ export const StationListProvider: FC<Props> = ({ children }) => {
const [stationList, setStationList] = useState<StationProps[][]>([[]]); const [stationList, setStationList] = useState<StationProps[][]>([[]]);
useEffect(() => { useEffect(() => {
if (originalStationList.length === 0) return; if (Object.keys(originalStationList).length === 0) return;
const stationList = lineList.map((d) => const stationList = lineList.map((d) =>
originalStationList[d].map((a) => ({ originalStationList[d].map((a) => ({
...a,
StationNumber: a.StationNumber, StationNumber: a.StationNumber,
StationName: a.Station_JP, StationName: a.Station_JP,
})) }))
+9 -6
View File
@@ -6,16 +6,18 @@ import React, {
FC, FC,
} from "react"; } from "react";
import { lineList, getStationList } from "../lib/getStationList"; import { lineList, getStationList } from "../lib/getStationList";
import { StationProps } from "@/lib/CommonTypes"; import { OriginalStationList, StationProps } from "@/lib/CommonTypes";
type initialStateType = { type initialStateType = {
originalStationList: StationProps[][]; originalStationList: OriginalStationList;
setOriginalStationList: React.Dispatch<React.SetStateAction<StationProps[][]>>; setOriginalStationList: React.Dispatch<
React.SetStateAction<OriginalStationList>
>;
getStationData: (id: string) => StationProps[]; getStationData: (id: string) => StationProps[];
stationList: { StationNumber: string; StationName: string }[][]; stationList: { StationNumber: string; StationName: string }[][];
}; };
const initialState = { const initialState = {
originalStationList: [[]], originalStationList: {},
setOriginalStationList: () => {}, setOriginalStationList: () => {},
getStationData: () => [], getStationData: () => [],
stationList: [], stationList: [],
@@ -30,7 +32,8 @@ export const useTopMenu = () => {
}; };
export const TopMenuProvider: FC<Props> = ({ children }) => { export const TopMenuProvider: FC<Props> = ({ children }) => {
const [originalStationList, setOriginalStationList] = useState<StationProps[][]>([]); const [originalStationList, setOriginalStationList] =
useState<OriginalStationList>({});
useEffect(() => { useEffect(() => {
getStationList().then(setOriginalStationList); getStationList().then(setOriginalStationList);
}, []); }, []);
@@ -47,7 +50,7 @@ export const TopMenuProvider: FC<Props> = ({ children }) => {
}; };
const [stationList, setStationList] = useState<{ StationNumber: string; StationName: string }[][]>([[]]); const [stationList, setStationList] = useState<{ StationNumber: string; StationName: string }[][]>([[]]);
useEffect(()=>{ useEffect(()=>{
if(originalStationList.length === 0) return; if (Object.keys(originalStationList).length === 0) return;
const stationList = const stationList =
originalStationList && originalStationList &&
lineList.map((d) => lineList.map((d) =>
+165 -12
View File
@@ -7,6 +7,10 @@ import React, {
useCallback, useCallback,
FC, FC,
} from "react"; } 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 { ASCore } from "../storageControl";
import { AS } from "../storageControl"; import { AS } from "../storageControl";
@@ -26,12 +30,16 @@ import {
import { import {
TrainRecording, TrainRecording,
RecordingMeta, RecordingMeta,
RecordingImportResult,
saveRecording, saveRecording,
loadRecordingList, loadRecordingList,
loadRecordingById, loadRecordingById,
deleteRecordingById, deleteRecordingById,
migrateOldRecording, migrateOldRecording,
generateRecordingId, generateRecordingId,
buildRecordingExportText as buildRecordingExportTextCore,
buildAllRecordingsExportText as buildAllRecordingsExportTextCore,
importRecordingsFromText as importRecordingsFromTextCore,
} from "../lib/mockApi/trainRecorder"; } from "../lib/mockApi/trainRecorder";
import { useNotification } from "../stateBox/useNotifications"; import { useNotification } from "../stateBox/useNotifications";
@@ -72,6 +80,10 @@ const initialState = {
setTrainMenu: (e) => {}, setTrainMenu: (e) => {},
updatePermission: false, updatePermission: false,
setUpdatePermission: (e) => {}, setUpdatePermission: (e) => {},
/** バックエンドが返したユーザーロール */
userPermissionRole: "",
/** crew/administrator向け音声機能の表示・利用権限 */
restrictedSoundPermission: false,
/** 各情報ソースの利用権限 */ /** 各情報ソースの利用権限 */
dataSourcePermission: { unyohub: false, elesite: false } as { dataSourcePermission: { unyohub: false, elesite: false } as {
unyohub: boolean; unyohub: boolean;
@@ -99,6 +111,8 @@ const initialState = {
activeRecording: null as TrainRecording | null, activeRecording: null as TrainRecording | null,
/** 現在再生中のスナップショットインデックス */ /** 現在再生中のスナップショットインデックス */
playbackIndex: 0, playbackIndex: 0,
/** 再生中スナップショットに対応する基準時刻(通常時は null) */
playbackCurrentTimeIso: null as string | null,
/** 再生一時停止中か */ /** 再生一時停止中か */
playbackPaused: false, playbackPaused: false,
startRecording: () => {}, startRecording: () => {},
@@ -111,6 +125,11 @@ const initialState = {
seekToSnapshot: (_index: number) => {}, seekToSnapshot: (_index: number) => {},
addTrainSnapshot: (_trains: TrainEntry[]) => {}, addTrainSnapshot: (_trains: TrainEntry[]) => {},
deleteRecording: (_id: string): Promise<void> => Promise.resolve(), 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); const TrainMenuContext = createContext(initialState);
@@ -136,34 +155,66 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
const [LoadError, setLoadError] = useState(false); const [LoadError, setLoadError] = useState(false);
// バックエンドAPIベースURL(環境設定から読み込み) // バックエンドAPIベースURL(環境設定から読み込み)
const [backendApiBaseUrl, setBackendApiBaseUrl] = useState( const [backendApiBaseUrl, setBackendApiBaseUrl] = useState<string>(
BACKEND_API_BASE_URLS.production, BACKEND_API_BASE_URLS.production,
); );
const [diagramTodayUrl, setDiagramTodayUrl] = useState( const [diagramTodayUrl, setDiagramTodayUrl] = useState<string>(
"https://jr-shikoku-api-data-storage.haruk.in/tmp/diagram-today.json", "https://jr-shikoku-api-data-storage.haruk.in/tmp/diagram-today.json",
); );
//更新権限所有確認・情報ソース別利用権限(将来ロールが増えたらここに足す) //更新権限所有確認・情報ソース別利用権限(将来ロールが増えたらここに足す)
const [updatePermission, setUpdatePermission] = useState(false); const [updatePermission, setUpdatePermission] = useState(false);
const [userPermissionRole, setUserPermissionRole] = useState("");
const [restrictedSoundPermission, setRestrictedSoundPermission] =
useState(false);
const [dataSourcePermission, setDataSourcePermission] = useState<{ const [dataSourcePermission, setDataSourcePermission] = useState<{
unyohub: boolean; unyohub: boolean;
elesite: boolean; elesite: boolean;
}>({ unyohub: false, elesite: false }); }>({ unyohub: false, elesite: false });
useEffect(() => { useEffect(() => {
if (!expoPushToken) return; 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( fetch(
`${backendApiBaseUrl}/check-permission?user_id=${expoPushToken}`, `${backendApiBaseUrl}/check-permission?user_id=${expoPushToken}`,
{ signal: permissionController.signal },
) )
.then((res) => res.json()) .then((res) => res.json())
.then((res) => { .then((res) => {
if (permissionController.signal.aborted) return;
const role: string = res.permission ?? ""; const role: string = res.permission ?? "";
setUpdatePermission(role === "administrator"); const normalizedRole = role.trim().toLowerCase();
const isAdministrator = normalizedRole === "administrator";
setUserPermissionRole(normalizedRole);
setUpdatePermission(isAdministrator);
setRestrictedSoundPermission(
normalizedRole === "crew" || isAdministrator,
);
setDataSourcePermission({ setDataSourcePermission({
unyohub: role === "administrator" || role === "unyoHubEditor", unyohub: isAdministrator || role === "unyoHubEditor",
elesite: role === "administrator" || role === "eleSiteEditor", elesite: isAdministrator || role === "eleSiteEditor",
}); });
}) })
.catch(() => {}); .catch(() => {
if (permissionController.signal.aborted) return;
setUserPermissionRole("");
setUpdatePermission(false);
setRestrictedSoundPermission(false);
setDataSourcePermission({ unyohub: false, elesite: false });
});
return () => permissionController.abort();
}, [expoPushToken, backendApiBaseUrl]); }, [expoPushToken, backendApiBaseUrl]);
//列車情報表示関連 //列車情報表示関連
@@ -216,13 +267,25 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
const [recordingList, setRecordingList] = useState<RecordingMeta[]>([]); const [recordingList, setRecordingList] = useState<RecordingMeta[]>([]);
const [activeRecording, setActiveRecording] = useState<TrainRecording | null>(null); const [activeRecording, setActiveRecording] = useState<TrainRecording | null>(null);
const [playbackIndex, setPlaybackIndex] = useState(0); 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 [playbackPaused, setPlaybackPaused] = useState(false);
const recordingStartTimeRef = useRef<number>(0); const recordingStartTimeRef = useRef<number>(0);
const recordingSnapshotsRef = useRef<Array<{ t: number; trains: TrainEntry[] }>>([]); const recordingSnapshotsRef = useRef<Array<{ t: number; trains: TrainEntry[] }>>([]);
const refreshRecordingList = async () => {
const list = await loadRecordingList();
setRecordingList(list);
};
// 起動時: 旧フォーマット移行 → 一覧読み込み // 起動時: 旧フォーマット移行 → 一覧読み込み
useEffect(() => { useEffect(() => {
migrateOldRecording().then(() => loadRecordingList()).then(setRecordingList).catch(() => {}); migrateOldRecording().then(refreshRecordingList).catch(() => {});
}, []); }, []);
// 再生ループ: playbackIndex / recorderState / playbackPaused が変わるたびに次へ進める // 再生ループ: playbackIndex / recorderState / playbackPaused が変わるたびに次へ進める
@@ -260,8 +323,7 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
snapshots: snaps, snapshots: snaps,
}; };
await saveRecording(recording); await saveRecording(recording);
const list = await loadRecordingList(); await refreshRecordingList();
setRecordingList(list);
} }
setRecorderState('idle'); setRecorderState('idle');
}; };
@@ -306,8 +368,92 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
const deleteRecording = async (id: string) => { const deleteRecording = async (id: string) => {
await deleteRecordingById(id); await deleteRecordingById(id);
const list = await loadRecordingList(); await refreshRecordingList();
setRecordingList(list); };
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 テキスト(位置マスターから) */ /** PosNum + Line → Pos テキスト(位置マスターから) */
@@ -448,6 +594,8 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
setTrainMenu, setTrainMenu,
updatePermission, updatePermission,
setUpdatePermission, setUpdatePermission,
userPermissionRole,
restrictedSoundPermission,
dataSourcePermission, dataSourcePermission,
injectJavascript, injectJavascript,
injectJavascriptBeforeContentLoaded, injectJavascriptBeforeContentLoaded,
@@ -462,6 +610,7 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
recordingList, recordingList,
activeRecording, activeRecording,
playbackIndex, playbackIndex,
playbackCurrentTimeIso,
playbackPaused, playbackPaused,
startRecording, startRecording,
stopRecording, stopRecording,
@@ -472,6 +621,10 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
seekToSnapshot, seekToSnapshot,
addTrainSnapshot, addTrainSnapshot,
deleteRecording, deleteRecording,
exportRecordingFile,
exportAllRecordingsFile,
importRecordingFile,
importRecordingsFromText,
}} }}
> >
{children} {children}
+22 -8
View File
@@ -12,6 +12,7 @@ import {
} from "expo-location"; } from "expo-location";
import { Platform } from "react-native"; import { Platform } from "react-native";
import useInterval from "@/lib/useInterval"; import useInterval from "@/lib/useInterval";
import { logger } from "@/utils/logger";
type initialStateType = { type initialStateType = {
position: LocationObject | undefined; position: LocationObject | undefined;
@@ -42,17 +43,30 @@ export const UserPositionProvider: FC<Props> = ({ children }) => {
); );
const getLocationPermission = async () => { const getLocationPermission = async () => {
return requestForegroundPermissionsAsync().then((data) => { return requestForegroundPermissionsAsync()
setLocationStatus( .then((data) => {
Platform.OS == "ios" setLocationStatus(
? data.status == "granted" Platform.OS == "ios"
: data.android.accuracy == "fine" ? data.status == "granted"
); : data.android.accuracy == "fine"
}); );
})
.catch((error) => {
setLocationStatus(false);
if (__DEV__) {
logger.warn("Location permission request failed:", error);
}
});
}; };
const getCurrentPosition = () => { const getCurrentPosition = () => {
if (!locationStatus) return () => {}; if (!locationStatus) return () => {};
getCurrentPositionAsync({}).then((location) => setPosition(location)); getCurrentPositionAsync({})
.then((location) => setPosition(location))
.catch((error) => {
if (__DEV__) {
logger.warn("Current location fetch failed:", error);
}
});
}; };
useEffect(() => { useEffect(() => {
if (Platform.OS == "web") return; if (Platform.OS == "web") return;
+2 -1
View File
@@ -6,7 +6,8 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./*"] "@/*": ["./*"],
"expo-live-activity": ["./modules/expo-live-activity/src/index.ts"]
} }
}, },
"extends": "expo/tsconfig.base" "extends": "expo/tsconfig.base"
+1710 -1609
View File
File diff suppressed because it is too large Load Diff