Compare commits

..
Author SHA1 Message Date
harukin-expo-dev-env 7537eeeeea Merge commit '94816b8e9d824480358c8d7f7a77bd52f0542337' 2026-07-10 11:33:41 +00:00
harukin-expo-dev-env 94816b8e9d iOSおよびAndroidのビルド番号とバージョンコードを更新 2026-07-10 11:33:25 +00:00
harukin-expo-dev-env 76ec92e29e Merge commit '6cc10f618fae9e5208981a13eb2e151bc8bf515f' into patch/6.x 2026-07-10 11:30:13 +00:00
harukin-expo-dev-env 6cc10f618f Merge commit '290d08457ca6e21673b9b80a210fa261bca6dae5' into develop 2026-07-10 11:28:33 +00:00
harukin-expo-dev-env 290d08457c 履歴管理機能を追加し、ビューポートメタタグの管理を強化 2026-07-10 11:28:15 +00:00
harukin-expo-dev-env 37cb0bb674 Adjust train type badge rendering 2026-07-09 10:15:49 +00:00
harukin-expo-dev-env f7cce5fd2e 各種機能の改善と最適化、ナビゲーションの明示的なターゲット設定を追加 2026-07-08 14:39:15 +00:00
harukin-expo-dev-env 34183b85d9 各種データの取得フラグ機能を実装、動作の最適化 2026-07-08 01:39:29 +00:00
harukin-expo-dev-env 8e1ceb01d9 Tune data fetch observability after first run 2026-07-07 04:10:24 +00:00
harukin-expo-dev-env c12e70787b Add data fetch observability 2026-07-07 02:47:13 +00:00
harukin-expo-dev-env 74f88e63d1 Work around Android user location map crash 2026-07-06 17:44:15 +00:00
harukin-expo-dev-env ab9c293c49 Initialize root tab state on startup 2026-07-06 08:42:28 +00:00
harukin-expo-dev-env 866e59eaf1 Tighten hidden tab preload timing 2026-07-06 08:31:08 +00:00
harukin-expo-dev-env b01ad87003 Add hidden webview startup preload 2026-07-06 08:25:25 +00:00
harukin-expo-dev-env 8329d0f301 Run hidden prewarm without interaction gate 2026-07-06 07:50:39 +00:00
harukin-expo-dev-env 438104577f Prewarm operation tab from top menu startup 2026-07-06 07:01:06 +00:00
harukin-expo-dev-env f42f97507c Restore Android carousel parallax mode 2026-07-06 06:20:13 +00:00
harukin-expo-dev-env 478f62e7f5 Restore Android carousel and add runtime flags 2026-07-06 05:58:03 +00:00
harukin-expo-dev-env 9e10c1dea9 Disable iOS screen optimizations for webviews 2026-07-04 16:33:33 +00:00
harukin-expo-dev-env 0cad9159ce Stabilize root tab screen mounting 2026-07-04 16:26:59 +00:00
harukin-expo-dev-env babce364e9 Stabilize positions webview retention on iOS 2026-07-04 16:22:35 +00:00
harukin-expo-dev-env 50e0e250aa Guard iOS webview remount on tab blur 2026-07-04 15:57:04 +00:00
harukin-expo-dev-env 2a4271dd92 Preserve operation webview and quiet noisy defaults 2026-07-04 15:48:37 +00:00
harukin-expo-dev-env 7165f08c67 Stabilize background WebView retention 2026-07-04 15:45:01 +00:00
harukin-expo-dev-env d571ac9848 Adjust WebView remount behavior for background tabs 2026-07-03 10:24:41 +00:00
harukin-expo-dev-env 6bf50f7e75 factor code structure for improved readability and maintainability 2026-07-02 02:37:34 +00:00
harukin-expo-dev-env f07cff6522 Merge commit '076fe7b1e933b0f79197e20ddbcc4d87038d89aa' into develop 2026-07-01 12:10:45 +00:00
harukin-expo-dev-env 076fe7b1e9 インポート機能の作成、録画システムのポジション最適化 2026-06-29 16:11:42 +00:00
harukin-expo-dev-env 1f0e84c386 Merge commit '998cda86e4dc02c8512c260936932d65b44d3c24' into develop 2026-06-29 14:43:20 +00:00
harukin-expo-dev-env 998cda86e4 Update signage view configuration 2026-06-29 14:42:44 +00:00
harukin-expo-dev-env 6ee6fad583 Add recording JSON import and export 2026-06-29 14:39:30 +00:00
harukin-expo-dev-env f61ea667ce Sentryロギングシステムを追加し、アプリのエラートラッキングを強化 2026-06-29 06:08:29 +00:00
harukin-expo-dev-env 35b8decb6d Merge commit '3ffe93f9c35c89aaed83580ec85250a8cdea8f5d' into develop 2026-06-27 04:37:27 +00:00
harukin-expo-dev-env 3ffe93f9c3 Merge commit '93be397b94991237814da29c7005bfb3bfd1f768' into patch/6.x 2026-06-27 04:35:48 +00:00
harukin-expo-dev-env 93be397b94 要素のサイズ変更 2026-06-27 04:33:54 +00:00
harukin-expo-dev-env 8bafc87ffc Merge commit 'c7b219cf0432bcd4dfadb9bd6fc38e95e38b761e' into develop 2026-06-25 17:46:42 +00:00
harukin-expo-dev-env 27b125643e Merge commit 'c7b219cf0432bcd4dfadb9bd6fc38e95e38b761e' 2026-06-25 17:37:17 +00:00
harukin-expo-dev-env c7b219cf04 7.0.7 release 2026-06-25 17:36:48 +00:00
harukin-expo-dev-env bd6a9cc5a3 Merge commit 'fe0d7146d98a74aba05b7838bd6931001f87c1ea' into patch/6.x 2026-06-25 17:35:52 +00:00
harukin-expo-dev-env fe0d7146d9 Merge commit '861406a80f2ac922ea01ab34d51a4bb0e25a7439' into develop 2026-06-25 17:16:17 +00:00
harukin-expo-dev-env 861406a80f 小見出しも並べられるように変更 2026-06-25 16:57:20 +00:00
harukin-expo-dev-env e18c9c0545 Hubアイコンを権限餅専用に 2026-06-25 16:20:04 +00:00
harukin-expo-dev-env d35f0accfc 団体臨時から臨時を削除 2026-06-25 16:01:31 +00:00
harukin-expo-dev-env a2c66455d0 Merge commit 'f559934172d2711f580b3d40b4d5cf8604b7fa27' into develop 2026-06-25 14:27:38 +00:00
harukin-expo-dev-env f559934172 切り替えスイッチを設置 2026-06-25 14:27:24 +00:00
harukin-expo-dev-env cabd9a9e27 レイアウト機能調整 2026-06-25 13:26:51 +00:00
harukin-expo-dev-env 8f9e98eb05 Merge commit '6f124769b538054ebb61860bf69d016efcb30114' into feature/train-info-edit 2026-06-23 17:12:37 +00:00
harukin-expo-dev-env 6f124769b5 Merge commit '9a7dc5216b64781c0c82de67dc4831a17412b162' into develop 2026-06-23 17:12:31 +00:00
harukin-expo-dev-env 9a7dc5216b アイコンステータスの表示改善 2026-06-23 17:11:28 +00:00
harukin-expo-dev-env 8fe01c0520 unyohubアイコン対応 2026-06-23 16:40:44 +00:00
harukin-expo-dev-env 15e1e63f9b 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 58733cb3e5 feat: 列車情報のキャプチャ機能を追加し、スタイルを改善 2026-06-10 05:04:09 +00:00
harukin-expo-dev-env b223af59f5 Merge commit 'b659ac6fad044a46de5eb261c391e83c92c6feed' into develop 2026-06-06 08:35:26 +00:00
harukin-expo-dev-env 6c71e8100c Merge commit 'b659ac6fad044a46de5eb261c391e83c92c6feed' 2026-06-05 12:48:34 +00:00
harukin-expo-dev-env b659ac6fad fix: バージョンコードを7.0.6に更新 2026-06-05 12:48:19 +00:00
harukin-expo-dev-env c85d8a4c6f Merge commit 'aa11648e3a2c2b3a95b8c1423b44fe1db04ff42f' into patch/6.x 2026-06-05 12:35:07 +00:00
harukin-expo-dev-env aa11648e3a feat: えれサイトの列車情報をグループ化し、表示を改善 2026-06-05 12:31:54 +00:00
harukin-expo-dev-env 3ea9b712c2 feat: 録画の削除機能を追加し、スワイプ操作での削除を実装 2026-06-01 12:11:00 +00:00
harukin-expo-dev-env 56c051a65b Merge commit 'e9f2b1b8bba58985c1214def5db483f7b78f4939' into develop 2026-05-20 15:29:09 +00:00
harukin-expo-dev-env 4130d07645 Merge commit 'e9f2b1b8bba58985c1214def5db483f7b78f4939' 2026-05-20 14:27:21 +00:00
harukin-expo-dev-envandCopilot e9f2b1b8bb 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 51ed4b7e4a Merge commit '0330f826ebcb931c0c76789f5660421fd3f02a4b' 2026-05-20 06:18:26 +00:00
harukin-expo-dev-env 9b5dc7c59b Merge commit '0330f826ebcb931c0c76789f5660421fd3f02a4b' into develop 2026-05-20 06:18:08 +00:00
harukin-expo-dev-env 0330f826eb fix: update version code to 7.0.5 in SettingTopPage component 2026-05-20 06:17:56 +00:00
harukin-expo-dev-env 4dd455bd0d feat: add button for paper timetable in FixedContentBottom component 2026-05-20 06:02:50 +00:00
harukin-expo-dev-env 3adb9e9baf Merge commit 'f1334c699bcf924edc60a34d46250cc79770028d' into patch/6.x 2026-05-19 03:07:03 +00:00
harukin-expo-dev-env f1334c699b feat: add experimental diagram URL support and update related components 2026-05-19 03:06:45 +00:00
harukin-expo-dev-env 1276a175fb Merge commit 'e2f775e94dda6bfc55a8d16c88d0c39a4a4665a9' into patch/6.x 2026-05-15 17:22:28 +00:00
harukin-expo-dev-env e2f775e94d 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 5d6dfee322 Merge commit '6306ebc2864089c6b33133497b956f665f1c93ea' into develop 2026-05-05 10:43:58 +00:00
harukin-expo-dev-env 38df72027a Merge commit '6306ebc2864089c6b33133497b956f665f1c93ea' 2026-05-05 09:10:42 +00:00
harukin-expo-dev-env 6306ebc286 feat: add Koboke Observatory to tourist spots data 2026-05-05 09:10:34 +00:00
harukin-expo-dev-env 49fa54d4f5 Merge commit '3b94cbafdd96f6b89d02399238148e1bf92e0b0c' 2026-05-05 08:14:20 +00:00
harukin-expo-dev-env 3b94cbafdd fix: update version code to 7.0.4 2026-05-05 08:12:25 +00:00
harukin-expo-dev-env 3ed6fdd1f1 Merge commit 'a9e7185002f9764dacc12afb87c8a66912428f0f' into patch/6.x 2026-05-05 08:10:22 +00:00
harukin-expo-dev-env a9e7185002 Merge commit '21de72634a20a2dc0f05f421348adc1cb4b80d0d' into develop 2026-05-05 08:09:57 +00:00
harukin-expo-dev-envandCopilot 21de72634a 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 b45c0aa876 Merge commit '9c40daee8300344d00f28e6cd95a367f6c6f43fa' into develop 2026-05-05 08:04:56 +00:00
harukin-expo-dev-env 9c40daee83 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 d992ad65f3 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 f99d68cf6e 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 a4195788b9 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 a0663846fa Merge commit '72a7978bb8cf3b3b9b8a791fc0bdde674d75358a' 2026-05-02 09:15:40 +00:00
harukin-expo-dev-env c3057120b7 Merge commit '72a7978bb8cf3b3b9b8a791fc0bdde674d75358a' into develop 2026-05-02 09:15:31 +00:00
harukin-expo-dev-env 72a7978bb8 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 ef95346310 Merge commit '72bfeedf8a61d019cc5081b0538fc4c2f06930f4' 2026-05-02 03:01:38 +00:00
harukin-expo-dev-env edc97cde69 Merge commit '72bfeedf8a61d019cc5081b0538fc4c2f06930f4' into develop 2026-05-02 03:01:15 +00:00
harukin-expo-dev-env 72bfeedf8a fix: update version code to 7.0.3 2026-05-02 02:59:29 +00:00
harukin-expo-dev-env ff4c2a0021 Merge commit 'c19543dc1a50eca579b5670c16a992d211d31a6f' into patch/6.x 2026-05-02 02:53:10 +00:00
harukin-expo-dev-env c19543dc1a fix: update header colors in injected JavaScript for better visibility 2026-05-02 02:52:22 +00:00
harukin-expo-dev-envandCopilot 039909362e 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 21dc537071 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 94b969ff4e 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 1e5ba9c4f0 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 27d4f71e25 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 d7f04eb1bf fix: update useWebViewRemount to include backgroundThresholdMs option for better app state handling 2026-05-01 23:25:50 +00:00
harukin-expo-dev-envandCopilot a025af73d3 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 1498cbdfac 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 f698fd999f 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 e095402601 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 fcdad893bf 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 89bf6682c7 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 475ee7decb 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 762c685e60 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 319befc7ad 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 ee4e048972 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 8ec7fe87d0 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 7b8247d61d 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 bf72f49cb7 fix: add platformNum property to train data structures for improved display 2026-04-30 15:18:28 +00:00
harukin-expo-dev-env a755f7969d fix: add loading and error handling in GeneralWebView component with reload functionality 2026-04-30 14:27:15 +00:00
harukin-expo-dev-env c015b76ac0 fix: expose setReload function to the global window object for accessibility 2026-04-30 14:01:55 +00:00
harukin-expo-dev-env f63a6d0b05 fix: trim train numbers in sorting and filtering logic for accurate matching 2026-04-30 09:49:44 +00:00
harukin-expo-dev-env d3df76a622 feat: add FormationChips, FadingSubCycler, and ActiveFormationChipsCycler components for enhanced train data visualization 2026-04-30 08:59:46 +00:00
harukin-expo-dev-envandCopilot c2a115bc06 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 b5be076ef5 Merge commit '496db1a32dfee8af3e61cb7ffcf1606724289be3' into develop 2026-04-29 11:15:58 +00:00
harukin-expo-dev-env 496db1a32d feat: add changelog for version update from 43e37b49 to 7c5793bd, 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 7c5793bde8 Merge commit '17af25ce67088dc61c9ae87da1c8d12d23862d51' 2026-04-26 08:49:39 +00:00
harukin-expo-dev-env 43e37b49dc Merge commit 'fca404878dab04a60ea56a42f066710e847173f0' 2026-04-01 11:14:00 +00:00
harukin-expo-dev-env 8faa4886ef Merge commit '6b9f5634f2e02b5e3dd3e914fe8dddddde372199' 2026-04-01 10:23:45 +00:00
harukin-expo-dev-env 32c7719ea7 Merge commit '0e57b0bf50e2ea9900db91f2f31eb4829c08c1ce' 2026-03-31 17:42:40 +00:00
harukin-expo-dev-env 8bc43ed8c8 Merge commit '0f52441b17081cd78dd706c84b66f0e40cf5fd2f' 2026-03-06 15:47:21 +00:00
harukin-expo-dev-env 3e64cbee9a Merge commit '3a182d4650453d9a84227b83333901d71787a55c' 2026-03-06 14:53:06 +00:00
harukin-expo-dev-env 7d454cf9d4 Merge commit '09c00202247c0c97f1d8c324c2cc49214eee1393' 2026-02-07 09:04:47 +00:00
harukin-expo-dev-env 699e3039b9 Merge commit '935b63f6cee9d84530a5e7f6e8f55bcc90f8a168' 2026-02-01 13:18:49 +00:00
harukin-expo-dev-env 5af3164f21 Merge commit 'ef81c1f4cdc325f66a55b9f06212190dbec5be24' 2025-12-31 16:08:13 +00:00
harukin-expo-dev-env cd585b8206 Merge commit 'bb1ee2666e5c91819bc3330128a9636b5e2ad753' 2025-12-31 09:52:36 +00:00
harukin-expo-dev-env 927c73567a Merge commit '0bf345ff6ac492e66da91e71db0118adb58cb28a' 2025-12-20 10:14:56 +00:00
harukin-expo-dev-env 09170c6636 Merge commit '24f32335c48eda61d312eceaf6bdd1e442c63f60' 2025-12-12 19:27:28 +00:00
harukin-expo-dev-env 8705f69725 Merge commit '2975e9094e17963b726753d954ba347226af98e2' 2025-12-05 15:25:11 +00:00
harukin-expo-dev-env 9562e04ffe Merge commit '6f74f5dfa26369dc4ce15435eccb9af51b5842e3' 2025-11-06 01:41:13 +00:00
harukin-expo-dev-env 2ffb47b60c Merge commit '09f700fb667a9dc0b05488e05c3d9cd3c31ffaae' 2025-11-06 01:33:37 +00:00
harukin-expo-dev-env 1208a78831 Merge commit 'efd9b77cadfbff4205e17388e7b2a6cad637ba9f' 2025-09-29 18:43:34 +00:00
harukin-expo-dev-env 493ef92bd6 Merge commit 'dfaf5b05b93f8fdcddb17da2a4981faffc28e0ac' 2025-09-29 06:08:16 +00:00
harukin-expo-dev-env a51ffe4a82 Merge commit 'fa1562f870e8f5fa26db931785dd297eeb834c56' 2025-09-28 19:50:05 +00:00
harukin-expo-dev-env 0ea25f1e97 Merge commit '19f9b58497df949ff20cbad00a344118ac5740cd' 2025-09-27 17:51:11 +00:00
harukin-expo-dev-env 16bd0fe192 Merge commit '59e7ba5290bd4f175c9dafbc0dd664b75417bf2f' 2025-09-25 03:00:42 +00:00
harukin-expo-dev-env d75a495b19 Merge commit '0d9c1cdb186a2cecc2e0aec441d9af9ab6ff56a6' 2025-09-11 05:55:26 +00:00
harukin-expo-dev-env a346a1477c Merge commit 'bc4cb450a3dfb0005d7e815019b6317953478a9f' 2025-09-09 16:16:57 +00:00
harukin-expo-dev-env c93ca94a61 Merge commit '9b4c0735b0475659aa7fe1f688b8daef13b714f1' 2025-08-27 01:20:09 +00:00
harukin-expo-dev-env 4480c161d1 Merge commit '087f6c882968bdef782bfe2aa0005a67fe436c7c' 2025-08-24 11:30:06 +00:00
harukin-expo-dev-env deb9b40949 Merge commit 'ddaad38ccc2d23121f7fb6e3678417e7ac9ed593' 2025-08-24 04:49:29 +00:00
harukin-expo-dev-env fced009607 Merge commit 'eabb2499fa43ba6a5adb3ae71e18067d86d63e35' 2025-08-19 19:19:24 +00:00
harukin-expo-dev-env 0890c1b9ff Merge commit '7bbb5b972fdb2eb429e5f71d6dd30e279b47ad10' 2025-08-13 12:50:56 +00:00
harukin-expo-dev-env 4b3816940b Merge commit 'ff4eb2c95f72581771b09ea820e9413540684e7b' 2025-08-08 10:54:00 +00:00
harukin-expo-dev-env 9ea23d6eba Merge commit '3dbaa6bfbf2bf18d01464c5e367c95593250dc85' 2025-07-27 04:12:01 +00:00
harukin-expo-dev-env 31ea303b88 Merge commit '9b266c15f80301e0b7a47cfc702c52beec36f4c4' 2025-07-27 03:54:24 +00:00
harukin-expo-dev-env 7716cb516c Merge commit 'dc7cc555c990fd4a2cd5cf47089339c887eecc75' 2025-07-18 15:42:20 +00:00
harukin-expo-dev-env 70976c0554 Merge commit '8a7285bb20c76d6e99cafd96193957829906f9ac' 2025-07-08 16:44:25 +00:00
harukin-expo-dev-env 5616c7ed96 Merge commit '8212148fb298dc678b811aadf0344731b43e8cf7' 2025-05-04 12:56:37 +00:00
harukin-expo-dev-env 13580a57d4 Merge commit 'bd2248e1a943e574ccc44892122cd9da4df14b68' 2025-04-20 17:22:59 +00:00
harukin-expo-dev-env c9481fb0c2 Merge commit 'b64138178c4ca99bfcbb4f115403a18674ce8136' 2025-04-14 19:10:10 +00:00
harukin-expo-dev-env 5cda45740c Merge commit '67f40b55c19ff2e94b1168865486bcde7d457cab' 2025-04-10 16:38:21 +00:00
harukin-expo-dev-env 84d1305796 Merge commit 'fdefbc82bd39c8cba29911273db2f96fa39c0e3f' 2025-04-10 15:55:13 +00:00
harukin-expo-dev-env 336e3510fa Merge commit 'af8f3333cd68ea507675ad02ec59ccf2f7959e00' 2025-03-30 02:52:30 +00:00
harukin-expo-dev-env 25780e1664 Merge commit '0a364021ce2fed98257e80cc780ac4020cecb9c1' 2025-03-25 07:27:06 +00:00
harukin-expo-dev-env e6fab84393 Merge commit 'de2b94292dc97df6e290e91976d01cea24c98ced' 2025-03-22 12:33:34 +00:00
harukin-expo-dev-env 390acdeab7 Merge commit '8c75e06ac18e33588b5bf200d7fccf05d3c34c22' 2025-03-22 11:34:25 +00:00
harukin-expo-dev-env ff7a5624e6 Merge commit '45500e7a4ae614debd93404339958465db88f71d' 2025-03-19 15:49:36 +00:00
harukin-expo-dev-env 9b79f224d3 Merge commit '2c5023568a0442679b476bd84651eca3fcc90ee1' 2025-03-16 05:56:14 +00:00
harukin-expo-dev-env 2f76e3776f Merge commit '8a48bc48e6eb82921fcda3a84e07db2eca51e614' 2025-03-15 04:23:16 +00:00
harukin-expo-dev-env 026da47d82 Merge commit '23fb2d715a461cb0ebfca1e1944757febd315fec' 2025-03-14 18:29:20 +00:00
harukin-expo-dev-env 29be052bf6 Merge commit 'ab883827e13b5c99556857395ac950ee00c53b83' 2025-03-11 05:26:18 +00:00
harukin-expo-dev-env 2fb7d97d33 Merge commit '31618aab496a7979a39e534d023cb69103b06721' 2025-03-10 15:51:25 +00:00
harukin-expo-dev-env 76ebf8055b Merge commit '26cf84705abd4c9a69ac56fcade483a373089bf8' 2025-03-06 03:52:42 +00:00
harukin-expo-dev-env 7b572cd657 Merge commit 'fa882223d49fec390177131371b99a3101976759' 2025-03-05 11:51:51 +00:00
harukin-expo-dev-env 61fa0f8484 Merge commit 'ede2884c4a7b8a508cfd9f4f7cc87da4efe3db08' 2025-03-04 16:17:50 +00:00
harukin-expo-dev-env 935aaf2610 Merge commit '098cae50533368eec9cfdb933900c503ae46e850' 2025-03-04 15:01:08 +00:00
harukin-expo-dev-env 8ec53d6e06 Merge commit '0aaf171477659e632a23aeb6af39e83d5acf70c2' 2025-02-09 03:21:16 +00:00
harukin-expo-dev-env e8b1a21a3b Merge commit '4a01c529b47cd85420a4c1feeac87164730dee94' 2025-01-15 12:38:58 +00:00
harukin-expo-dev-env e6b89842a3 Merge commit '2776f17681ac2bf76a70c268c4db8921c5888a4f' 2025-01-14 07:42:06 +00:00
harukin-expo-dev-env 1fc5220405 Merge commit '58d3eae1d7797a74febc752f4185337a6eb95dad' 2025-01-09 09:56:21 +00:00
harukin-expo-dev-env 5489406578 Merge commit 'a7c4f689bc0762a63b662901f943a62827b765eb' 2024-11-22 05:40:18 +00:00
harukin-expo-dev-env 2b217e98c7 Merge commit 'd8108e2c9c60a60ba7f933af12af996b3cc0491c' 2024-11-22 05:19:00 +00:00
harukin-expo-dev-env 2c2e61a2fa Merge commit '6d3e6623a0774cb489efa3b81335db319fad2b2d' 2024-10-31 12:27:16 +00:00
harukin-expo-dev-env c222b303df Merge commit '1346909bb74c80bedf1841aefda66a67adb98443' 2024-09-30 13:00:12 +00:00
harukin-expo-dev-env 8f8c095ecd Merge commit 'ab2a18b562c5fe18087f3140449fc7764b969df6' 2024-09-28 20:45:33 +00:00
harukin-expo-dev-env e31e84ff34 Merge commit '9b91c4a50e1df914f66c6c5b33007caf4b0c9c32' 2024-09-20 15:18:49 +00:00
harukin-expo-dev-env 2c2b355a3e Merge commit '63209ac88878f70d0896dca429277d09ef1bf492' 2024-09-16 16:34:11 +00:00
harukin-expo-dev-env 5461087ca0 Merge commit 'c9a90809c6b178d5b85d51d47521d2541bbed7f6' 2024-09-16 16:31:31 +00:00
harukin-expo-dev-env 57c7285b6e Merge commit '628d2a42c94801ea42c939dd702ebd448176e62f' 2024-09-16 16:10:05 +00:00
harukin-expo-dev-env 5c134c95cc Merge commit '801b3dc3b0dde2f8a46962cfbca1599fb31cb6a4' 2024-09-02 02:29:04 +00:00
harukin-expo-dev-env 8781653fe8 Merge commit '9b4b4bd0d6572d0d0ffdb35ec72559bf035d07a3' 2024-06-05 10:27:22 +00:00
harukin-expo-dev-env 67ccc37c17 Merge commit '35e05e92f50ebca5d4666b9a008915b7f437f86c' 2024-06-05 09:16:05 +00:00
harukin-expo-dev-env 92caab03f5 Merge commit '52c2da333a97234bb27a25baf8af8479d53c3422' 2024-06-03 12:40:26 +00:00
harukin-expo-dev-env dd3a57b3ae Merge commit 'b9b983a177d37db717b551f8b18d5f6c87aa0e23' 2024-06-03 12:20:23 +00:00
harukin-expo-dev-env 8df32b9c1d Merge commit '7eea78027649dec4dd7492efd8edfb0e61df5eea' 2024-04-28 10:33:20 +00:00
harukin-expo-dev-env 4b901d5015 Merge commit '5d711d37550d288142e3e768a2a0f42a7279d434' 2024-04-18 03:32:42 +00:00
harukin-expo-dev-env fc5c62685a Merge commit '85e2ad329d89e57be4c094067de48525fe7673e3' 2024-04-18 03:21:26 +00:00
harukin-expo-dev-env fbc98b2ff7 Merge commit '05167c810a8b727ed6527dbb07c0b9c1de53c171' 2024-04-06 17:19:41 +00:00
harukin-expo-dev-env 3502043176 Merge commit '0efab93a1451818339bfbed97545e6782eafcb9c' 2024-04-06 17:19:32 +00:00
harukin-expo-dev-env 2d0ad8d59e Merge commit 'dd7da102c43348a5420a4b616509d37c8d03a65f' 2024-03-27 15:52:25 +00:00
harukin-expo-dev-env b5172df7a9 Merge commit '548d5d3747e6491ae421a716e85f7d60be4e41e4' 2024-03-27 15:02:35 +00:00
harukin-expo-dev-env 740d414d2d Merge commit '7e59b8c7bd7d01b35b5b0d6c5be93baed222f660' 2024-03-26 12:59:20 +00:00
harukin-expo-dev-env 8db3e6c218 Merge commit '736f9a65e92257237f347bbe2d02fe88f0e409d6' 2024-03-26 12:54:31 +00:00
harukin-expo-dev-env fb35b01d2e Merge commit '720b627011a108979ce07a103aff2d728e68cab0' 2024-03-18 12:50:34 +00:00
harukin-expo-dev-env 5fa6b1f73e Merge commit '481ca0158d93b90af0306ed3d54786d61d6281e2' 2024-03-18 04:17:25 +00:00
harukin-expo-dev-env c16f7401ea Merge commit '5864e821120ec726c6e96fbc8edc6a7172d5bbd7' 2024-03-18 04:04:16 +00:00
harukin-expo-dev-env f260c5d2dd Merge commit 'a769ccb9512d8c246be82cb1f16673b13b39d4e6' 2024-03-15 18:04:11 +00:00
harukin-expo-dev-env 80e9f1a869 Merge commit 'd8ce2a7f8aac23e1001bbe16690d37c57d1c704f' 2024-03-13 13:27:05 +00:00
harukin-expo-dev-env 26096ba244 Merge commit '5806e2a259e03182d082fe454f4a1932ad9c5e53' 2024-03-13 13:10:22 +00:00
harukin-expo-dev-env 435a910ef9 Merge commit 'f2aed4b945d3e86381ebb964787fcb5aff35fdbd' 2024-03-12 16:47:26 +00:00
harukin-expo-dev-env 68b9236d65 Merge commit '7bd7d951048357c4c0b81d9048906275066e8526' 2024-03-12 16:36:37 +00:00
harukin-expo-dev-env 0148c12e08 Merge commit '75952f4edea5d59554322a5c611483a0052e4e97' 2024-03-12 14:37:09 +00:00
harukin-expo-dev-env 34dc62aee6 Merge commit 'c0cdad36837f27dee7c22930834272052d53d090' 2024-03-09 15:48:41 +00:00
harukin-OneMix4 54ae681f00 Merge commit '8a94b81052a4cf6b833bf298abcd89ed93a0ada2' 2024-01-27 01:37:29 +09:00
harukin-OneMix4 db40351fec Merge commit '431c4c9c0b426250adefbb42db7893233fabb55b' 2024-01-27 01:04:19 +09:00
harukin-OneMix4 2dd8e62f85 Merge commit '485102b5917eaa819c615dbaf47ee27f81a5751f' 2024-01-12 14:04:37 +09:00
harukin-OneMix4 0937fbb619 Merge commit '65080e68f2197f31b8a4ed4bf94ad325aa77e923' 2024-01-09 18:31:30 +09:00
harukin-OneMix4 32edab3289 Merge commit '87129c6815233ea6e42575b83a26c0a2c34a6e5c' 2024-01-09 18:21:41 +09:00
harukin-OneMix4 fa96e68770 Merge commit 'b54f2f6f7e1478cee3f1f3cacf2bc3949d45583f' 2024-01-04 02:38:22 +09:00
harukin-OneMix4 1681be4437 Merge commit '0e06b6636862fa4728d36bb9b934490087cadf02' 2024-01-04 00:47:55 +09:00
harukin-OneMix4 7f96c44e88 Merge commit '0f0d69b22d4e8b3aea9bec06855d38751881055a' 2024-01-03 01:35:33 +09:00
harukin-OneMix4 7d485c466c Merge commit '29c84bcc1ce7bd1d2512b65ef812a313507d8650' 2024-01-03 01:07:31 +09:00
harukin-OneMix4 7869cbee6d Merge commit 'a4e85ff2e6bccb35ecf04f6bdf51ac193585b765' 2023-12-29 03:50:28 +09:00
harukin-OneMix4 c2ad681891 Merge commit '57459d975bbe3a1b4e145331b57a638b2c2fead5' 2023-12-29 02:09:44 +09:00
harukin-OneMix4 163f9ead18 Merge commit 'b23b59eab349139656b2d32c4b19f744a937aa59' 2023-12-25 17:09:41 +09:00
harukin-OneMix4 611fc9953f Merge commit 'e38f550b94a6c89fd2840ff95af392e8e9f49589' 2023-12-25 15:46:35 +09:00
harukin-OneMix4 fd95e99874 Merge commit '9b70843e9c6ed45ebce609952b0202f5ebf877d7' 2023-12-25 05:47:23 +09:00
harukin-OneMix4 f5d0f993db update three month update 2023-12-13 15:56:40 +09:00
harukin-OneMix4 b7e763d265 update three month update 2023-09-17 15:44:19 +09:00
harukin-OneMix4 0afa6f402b 4.5.4.1 2023-07-16 23:00:26 +09:00
harukin-OneMix4 eacce53775 Merge commit '84d36b88cc71ed591c9a50f086ab38cd08177758' 2023-07-16 23:00:05 +09:00
harukin-OneMix4 47b1eb325e なんかマージミスった? 2023-07-16 19:40:44 +09:00
harukin-OneMix4 a145e2c24f Merge commit '711f0bbe9f00200c02fb70757d7c0ca2d0060ed4' 2023-07-16 19:36:57 +09:00
harukin-OneMix4 862742cd3e update build number 2023-06-19 14:03:37 +09:00
harukin-OneMix4 d5f2d367dd Merge commit 'a3e8e3cbbe13e4fc137937493a91708b1dff7cae' 2023-06-18 21:05:04 +09:00
harukin-OneMix4 b1f72f18b5 Merge commit '81f394d6ca2ae418c89452acabe11196e4b7e940' 2023-05-10 17:19:18 +09:00
harukin-OneMix4 d4d86fb7eb 4.5.3 2023-05-10 17:13:10 +09:00
harukin-OneMix4 056a059a68 Merge commit 'bb115afe358eb7f2fb724beb1da13b2be24780b4' 2023-05-10 17:11:56 +09:00
harukin-DeskMini 12676d59cf 4.5.2リリース(本開放/ニュース更新) 2023-03-30 03:28:34 +09:00
harukin-DeskMini bca2b300d3 4.5.2リリース(封印開放) 2023-03-30 03:25:28 +09:00
harukin-DeskMini 406808ee58 Merge commit '47d84777e2a26235d5442a68ae3b6bcb07f580cb' 2023-03-30 03:21:25 +09:00
harukin-DeskMini a8aadb7f08 4.5.1 2023-03-27 07:01:47 +09:00
harukin-DeskMini b810405573 Merge commit '65dd6dd0fdd41f06cfb686d578f9d604185510d3' 2023-03-27 07:01:20 +09:00
harukin-DeskMini 6f62418426 Merge commit '9eca67901906b8852f1f1f2dfa814ed658514b4b' 2023-03-27 01:06:04 +09:00
harukin-DeskMini 88621dd1c8 Merge commit '98b22db4dbea611907c4d7a6a60b7323dbec7a76' 2023-03-27 01:02:57 +09:00
harukin-DeskMini 12d9c1f49d 封印忘れ物 2023-03-26 21:45:04 +09:00
harukin-DeskMini 30c2b8a2f9 4.5.0-リリース用一部封印 2023-03-26 21:42:49 +09:00
harukin-DeskMini 8bc7069c4e Merge commit '816d96d37be6537c9ebcf0be30c74fd154b80dc5' 2023-03-26 21:09:24 +09:00
harukin-OneMix4 737cd25539 Merge commit '64538e33f317c6fe395859673922f63c2e3b0514' 2023-03-02 17:37:40 +09:00
harukin-DeskMini 2c50e5af67 Merge commit 'e93fe7095ef717bd0ddebeb18f28038a374d83f6' 2023-02-01 17:16:10 +09:00
harukin-DeskMini ea677b4da5 Merge commit '40fb55c8cf5462a40a499ddcc69cb5313e559f84' 2023-01-29 22:10:09 +09:00
harukin-DeskMini 4525443e39 Merge commit 'e35ab09002641b3813d2055ff8107bd8088c4629' 2023-01-29 09:11:58 +09:00
65 changed files with 847 additions and 7974 deletions
+3 -17
View File
@@ -39,7 +39,6 @@ import {
startAppLifecycleCrashSentinel,
stopAppLifecycleCrashSentinel,
} from "./lib/observability/appLifecycleCrashSentinel";
import { migrateLegacyVoicepeakSettings } from "./lib/migrateLegacyVoicepeakSettings";
Sentry.init({
dsn: 'https://1090312e4cf501f5a455d523eff2d538@o4511646874664960.ingest.us.sentry.io/4511646880432128',
@@ -75,10 +74,7 @@ LogBox.ignoreLogs([
"ColorPropType will be removed",
]);
const nativeScreensDisabled =
Platform.OS === "ios" || Platform.OS === "android";
if (nativeScreensDisabled) {
if (Platform.OS === "ios") {
enableFreeze(false);
enableScreens(false);
}
@@ -91,17 +87,12 @@ if (Platform.OS === "android") {
export default Sentry.wrap(function App() {
useEffect(() => {
const nativeScreensMode = nativeScreensDisabled
? "screens-disabled"
: "default";
const rootTabSceneMode =
Platform.OS === "android" ? "opacity-fixed-z-v1" : "default";
const nativeScreensMode =
Platform.OS === "ios" ? "screens-disabled" : "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",
@@ -110,7 +101,6 @@ export default Sentry.wrap(function App() {
data: {
platform: Platform.OS,
nativeScreensMode,
rootTabSceneMode,
},
});
void startAppLifecycleCrashSentinel({ nativeScreensMode });
@@ -123,10 +113,6 @@ export default Sentry.wrap(function App() {
UpdateAsync();
}, []);
useEffect(() => {
void migrateLegacyVoicepeakSettings();
}, []);
useEffect(() => {
const openFelicaPage = (retryCount = 0) => {
if (!rootNavigationRef.isReady()) {
+6 -67
View File
@@ -1,5 +1,5 @@
import React from "react";
import { NavigationContainer, DarkTheme, DefaultTheme, type EventArg } from "@react-navigation/native";
import { NavigationContainer, DarkTheme, DefaultTheme } from "@react-navigation/native";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { Animated, Platform, ActivityIndicator, View, StyleSheet, StatusBar, useWindowDimensions, InteractionManager } from "react-native";
import { useNavigationState } from "@react-navigation/native";
@@ -15,16 +15,10 @@ import lineColorList from "./assets/originData/lineColorList";
import { stationIDPair } from "./lib/getStationList";
import "./components/ActionSheetComponents/sheets";
import { lastObservedRootRouteRef, positionsLifecycleRef, rootNavigationRef } from "./lib/rootNavigation";
import {
startupExplicitTargetRef,
waitForStartupNavigationResolution,
} from "./lib/rootNavigation";
import { fixedColors } from "./lib/theme/colors";
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,
@@ -253,7 +247,6 @@ export function AppContainer() {
const { selectedLine } = useTrainMenu();
const { width, height } = useWindowDimensions();
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);
@@ -285,45 +278,6 @@ export function AppContainer() {
const { isDark } = useThemeColors();
const lastRootNavStateRef = React.useRef("");
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 = {
prefixes: ["jrshikoku://"],
config: {
@@ -422,7 +376,6 @@ export function AppContainer() {
const [fontLoaded, error] = useFonts({
"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"),
"JNR-font": require("./assets/fonts/JNRfont_pict.ttf"),
"DiaPro": require("./assets/fonts/DiaPro-Regular.otf"),
@@ -505,7 +458,7 @@ export function AppContainer() {
};
}, [currentRootRoute, fontLoaded, hasVisitedInformation, navigationReady]);
if (!fontLoaded || !startupInitialRoute) {
if (!fontLoaded) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator size="large" />
@@ -545,9 +498,9 @@ export function AppContainer() {
<Tab.Navigator
id="rootTabs"
detachInactiveScreens={false}
initialRouteName={startupInitialRoute}
initialRouteName="topMenu"
screenListeners={({ route }) => ({
tabPress: (event: EventArg<"tabPress", true>) => {
tabPress: (event) => {
const rootState = rootNavigationRef.getState();
const activeRootRouteName =
(rootState?.routes?.[rootState.index ?? 0]?.name as keyof RootTabParamList | undefined) ??
@@ -579,30 +532,16 @@ export function AppContainer() {
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,
},
message: "positions root exit blocked while session unstable",
data: { route: route.name },
});
}
},
+24 -2
View File
@@ -19,6 +19,10 @@ import { useFavoriteStation } from "@/stateBox/useFavoriteStation";
import { optionData } from "@/lib/stackOption";
import { AllTrainDiagramView } from "@/components/AllTrainDiagramView";
import { useNavigation, useIsFocused } from "@react-navigation/native";
import {
startupExplicitTargetRef,
waitForStartupNavigationResolution,
} from "@/lib/rootNavigation";
import { news } from "@/config/newsUpdate";
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
import GeneralWebView from "@/GeneralWebView";
@@ -65,6 +69,23 @@ export function MenuPage() {
}, [height, isFocused, width]);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
await waitForStartupNavigationResolution();
if (cancelled) return;
const res = await AS.getItem(STORAGE_KEYS.START_PAGE);
if (res !== "true" || startupExplicitTargetRef.current) return;
navigation.navigate("positions");
} catch (e) {
//6.0以降false
AS.setItem(STORAGE_KEYS.START_PAGE, "false");
}
})();
//ニュース表示
AS.getItem(STORAGE_KEYS.NEWS_STATUS)
.then((d) => {
@@ -77,7 +98,9 @@ export function MenuPage() {
})
.catch((error) => logger.error("Error fetching icon setting:", error));
return undefined;
return () => {
cancelled = true;
};
}, []);
const scrollRef = useRef(null);
@@ -159,7 +182,6 @@ export function MenuPage() {
return (
<Stack.Navigator
id={null}
detachInactiveScreens={false}
screenOptions={{ cardStyle: { backgroundColor: bgColor } }}
screenListeners={({ route, navigation: stackNav }) => {
stackNavRef.current = stackNav;
-1
View File
@@ -188,7 +188,6 @@ export const Top = () => {
return (
<Stack.Navigator
id={null}
detachInactiveScreens={false}
screenOptions={{ cardStyle: { backgroundColor: bgColor } }}
screenListeners={({ route, navigation: stackNav }) => {
stackNavRef.current = stackNav;
+7 -5
View File
@@ -24,7 +24,7 @@
"**/*"
],
"ios": {
"buildNumber": "67",
"buildNumber": "66",
"supportsTablet": true,
"bundleIdentifier": "jrshikokuinfo.xprocess.hrkn",
"appleTeamId": "54CRDT797G",
@@ -41,7 +41,10 @@
],
"ITSAppUsesNonExemptEncryption": false,
"NSSupportsLiveActivities": true,
"NSSupportsLiveActivitiesFrequentUpdates": true
"NSSupportsLiveActivitiesFrequentUpdates": true,
"UIBackgroundModes": [
"audio"
]
},
"entitlements": {
"com.apple.developer.nfc.readersession.formats": [
@@ -54,7 +57,7 @@
},
"android": {
"package": "jrshikokuinfo.xprocess.hrkn",
"versionCode": 33,
"versionCode": 32,
"intentFilters": [
{
"action": "VIEW",
@@ -114,7 +117,6 @@
{
"fonts": [
"./assets/fonts/jr-nishi.otf",
"./assets/fonts/JR-WEST-PLUS.otf",
"./assets/fonts/DelaGothicOne-Regular.ttf",
"./assets/fonts/JNRfont_pict.ttf",
"./assets/fonts/DiaPro-Regular.otf"
@@ -131,7 +133,7 @@
[
"expo-location",
{
"locationWhenInUsePermission": "現在地近の駅表示と、列車追従中に次の停車駅への接近をりっかちゃん音声で通知するために使用ます。"
"locationWhenInUsePermission": "この位置情報は、リンク画面で現在地近の駅情報を取得するのに使用されます。"
}
],
[
Binary file not shown.
-27
View File
@@ -1,27 +0,0 @@
西日本方向ロゴ拡張「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 } from "react";
import React, { FC, useMemo, useState } from "react";
import { Text, View, TextStyle, TouchableOpacity } from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import { migrateTrainName } from "../../../lib/eachTrainInfoCoreLib/migrateTrainName";
@@ -16,7 +16,6 @@ import type { NavigateFunction } from "@/types";
import { useUnyohub } from "@/stateBox/useUnyohub";
import { useElesite } from "@/stateBox/useElesite";
import { useThemeColors } from "@/lib/theme";
import { formatWrappedTrainType, shouldWrapTrainType } from "@/lib/trainTypeWrap";
import { useResponsive } from "@/lib/responsive";
import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
@@ -76,7 +75,7 @@ export const HeaderText: FC<Props> = ({
const [
typeName,
trainName,
fontFamily,
fontAvailable,
isOneMan,
infogram,
priority,
@@ -97,7 +96,7 @@ export const HeaderText: FC<Props> = ({
to_data,
directions,
} = result;
const [typeString, fontFamily, isOneMan] = getStringConfig(
const [typeString, fontAvailable, isOneMan] = getStringConfig(
type,
trainNum,
);
@@ -109,7 +108,7 @@ export const HeaderText: FC<Props> = ({
(train_num_distance !== "" && !isNaN(parseInt(train_num_distance))
? ` ${parseInt(trainNum) - parseInt(train_num_distance)}`
: ""),
fontFamily,
fontAvailable,
isOneMan,
infogram,
priority,
@@ -122,7 +121,7 @@ export const HeaderText: FC<Props> = ({
return [
typeString,
"",
fontFamily,
fontAvailable,
isOneMan,
infogram,
priority,
@@ -135,7 +134,7 @@ export const HeaderText: FC<Props> = ({
return [
typeString,
`${to_data}行き`,
fontFamily,
fontAvailable,
isOneMan,
infogram,
priority,
@@ -150,7 +149,7 @@ export const HeaderText: FC<Props> = ({
migrateTrainName(
`${trainData[trainData.length - 1].split(",")[0]}行き`,
),
fontFamily,
fontAvailable,
isOneMan,
infogram,
priority,
@@ -212,7 +211,7 @@ export const HeaderText: FC<Props> = ({
hasUnyohubFormation ||
hasElesiteFormation;
const wrapType = shouldWrapTrainType(typeName, trainName);
const [isWrapped, setIsWrapped] = useState(false);
const openTrainInfoUrl = () => {
if (!trainInfoUrl) return;
@@ -252,23 +251,15 @@ export const HeaderText: FC<Props> = ({
}}
>
<Text
numberOfLines={wrapType ? 2 : 1}
style={{
fontSize: fontScale(20),
color: fixed.textOnPrimary,
fontFamily,
fontWeight: fontFamily ? undefined : "bold",
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
fontWeight: !fontAvailable ? "bold" : undefined,
marginRight: 5,
flexShrink: 0,
...(wrapType
? { width: fontScale(44), lineHeight: fontScale(22) }
: {}),
}}
>
{wrapType ? formatWrappedTrainType(typeName) : typeName}
{fontFamily === "JR-WEST-PLUS" ? (
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
) : null}
{isWrapped ? typeName.replace(/(.{2})/g, "$1\n").trim() : typeName}
</Text>
{isOneMan && <OneManText />}
</View>
@@ -300,6 +291,9 @@ export const HeaderText: FC<Props> = ({
: { fontSize: fontScale(17) }),
flexShrink: 1,
}}
onTextLayout={(e) => {
if (e.nativeEvent.lines.length > 1) setIsWrapped(true);
}}
>
{trainName}
</Text>
@@ -1,12 +1,11 @@
import { useState, useEffect } from 'react';
import { lineListPair, stationIDPair } from '@/lib/getStationList';
import { useStationList } from '@/stateBox/useStationList';
import { OriginalStationList } from '@/lib/CommonTypes';
const computeThroughStations = (
trainData: string[],
stationList: any[][],
originalStationList: OriginalStationList
originalStationList: Record<string, any[]>
): { trainDataWithThrough: string[]; haveThrough: boolean } => {
if (!trainData.length) return { trainDataWithThrough: [], haveThrough: false };
@@ -1082,7 +1082,7 @@ const TrainInfoDetail: FC<{
uwasa,
optional_text,
} = data;
const { fontFamily } = getTrainType({
const { fontAvailable } = getTrainType({
type: data.type,
whiteMode: false,
});
@@ -1120,16 +1120,13 @@ const TrainInfoDetail: FC<{
style={[
styles.typeTagText,
{
fontFamily,
fontWeight: fontFamily ? undefined : "bold",
paddingTop: fontFamily ? 2 : 0,
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
fontWeight: !fontAvailable ? "bold" : undefined,
paddingTop: fontAvailable ? 2 : 0,
},
]}
>
{typeName}
{fontFamily === "JR-WEST-PLUS" ? (
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
) : null}
</Text>
</View>
)}
+13 -14
View File
@@ -28,7 +28,6 @@ import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
import { InfogramText } from "./ActionSheetComponents/EachTrainInfoCore/HeaderTextParts/InfogramText";
import { resolveTrainIconEntries } from "@/lib/trainIconEntries";
import { TrainIconStack } from "./ActionSheetComponents/EachTrainInfoCore/TrainIconStack";
import { formatWrappedTrainType, shouldWrapTrainType } from "@/lib/trainTypeWrap";
export const AllTrainDiagramView: FC = () => {
const { colors, fixed } = useThemeColors();
@@ -108,7 +107,9 @@ export const AllTrainDiagramView: FC = () => {
if (directions != undefined) {
iconTrainDirection = directions ? true : false;
}
const [typeString, fontFamily, isOneMan] = getStringConfig(type, id);
const [isWrapped, setIsWrapped] = useState(false);
const [typeString, fontAvailable, isOneMan] = getStringConfig(type, id);
const trainNameString = (() => {
switch (true) {
@@ -133,7 +134,6 @@ export const AllTrainDiagramView: FC = () => {
return migrateTrainName(hoge + "行き");
}
})();
const wrapType = shouldWrapTrainType(typeString, trainNameString);
return (
<TouchableOpacity
style={{
@@ -164,23 +164,17 @@ export const AllTrainDiagramView: FC = () => {
>
{typeString && (
<Text
numberOfLines={wrapType ? 2 : 1}
style={{
fontSize: 20,
color: fixed.textOnPrimary,
fontFamily,
fontWeight: fontFamily ? undefined : "bold",
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
fontWeight: !fontAvailable ? "bold" : undefined,
marginRight: 5,
flexShrink: 0,
...(wrapType
? { width: 44, lineHeight: 22 }
: {}),
}}
>
{wrapType ? formatWrappedTrainType(typeString) : typeString}
{fontFamily === "JR-WEST-PLUS" ? (
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
) : null}
{isWrapped
? typeString.replace(/(.{2})/g, "$1\n").trim()
: typeString}
</Text>
)}
{isOneMan && <OneManText />}
@@ -201,6 +195,11 @@ export const AllTrainDiagramView: FC = () => {
color: fixed.textOnPrimary,
flexShrink: 1,
}}
onTextLayout={(e) => {
if (e.nativeEvent.lines.length > 1) {
setIsWrapped(true);
}
}}
>
{trainNameString}
</Text>
+12 -15
View File
@@ -6,24 +6,21 @@ import {
} from "react-native-android-widget";
import dayjs from "dayjs";
import { WidgetColors, widgetLightColors } from "./widget-theme";
import { API_ENDPOINTS } from "@/constants";
import type { OperationInfoSnapshot } from "@/types";
export const getInfoString = async () => {
// Fetch data from the server
const time = dayjs().format("HH:mm");
const response = await fetch(API_ENDPOINTS.OPERATION_INFO, {
cache: "no-store",
});
if (!response.ok) {
throw new Error(
`Operation information request failed: ${response.status}`
);
}
const snapshot = (await response.json()) as OperationInfoSnapshot;
const operationInfoText = snapshot.compatibility?.operationInfoText ?? "";
const text = operationInfoText === "" ? null : operationInfoText.split("^");
const text = await fetch(
"https://script.google.com/macros/s/AKfycbz80LcaEUrhnlEsLkJy0LG2IRO3DBVQhfNmN1d_0f_HvtsujNQpxM90SrV9yKWH_JG1Ww/exec"
)
.then((response) => response.text())
.then((data) => {
if (data !== "") {
return data.split("^");
}
return null;
});
//ToastAndroid.show(`${text}`, ToastAndroid.SHORT);
return { time, text };
};
@@ -58,7 +58,7 @@ type props = {
export const FixedStation: FC<props> = ({ stationID }) => {
const { colors, fixed } = useThemeColors();
const { mapSwitch, playbackCurrentTimeIso } = useTrainMenu();
const { mapSwitch } = useTrainMenu();
const {
currentTrain,
fixedPosition,
@@ -151,11 +151,11 @@ export const FixedStation: FC<props> = ({ stationID }) => {
if (!currentTrain) return () => {};
const data = trainTimeAndNumber
.filter((d) => currentTrain.map((m) => m.num).includes(d.train)) //現在の列車に絞る[ToDo]
.filter((d) => trainTimeFiltering({ d, currentTrain, station, stationList, now: playbackCurrentTimeIso })) //時間フィルター
.filter((d) => trainTimeFiltering({ d, currentTrain, station })) //時間フィルター
.filter((d) => !d.isThrough)
.filter((d) => d.lastStation != station[0].Station_JP); //最終列車表示設定
setSelectedTrain(data);
}, [trainTimeAndNumber, currentTrain, stationList, playbackCurrentTimeIso, tick /*liveActivity periodic refresh*/]);
}, [trainTimeAndNumber, currentTrain, tick /*liveActivity periodic refresh*/]);
useEffect(() => {
liveNotifyIdRef.current = liveNotifyId;
@@ -259,7 +259,7 @@ export const FixedStation: FC<props> = ({ stationID }) => {
const player = delayAnnouncementPlayerRef.current;
setAudioModeAsync({
playsInSilentMode: true,
shouldPlayInBackground: false,
shouldPlayInBackground: true,
interruptionMode: "duckOthers",
})
.then(() => {
@@ -332,14 +332,10 @@ export const FixedStation: FC<props> = ({ stationID }) => {
}, [selectedTrain, currentTrain, liveNotifyId, buildTrainsInfo]);
// バナー表示と同時にLive Activityを自動開始(selectedTrainが揃ってから)
// iOSのみ一時的に無効化中(Androidは有効)
useEffect(() => {
if (
!isLiveActivityAvailable() ||
hasStartedRef.current ||
station.length === 0
) {
return;
}
// iOSのLive Activityは無効化
if (Platform.OS === 'ios') return;
hasStartedRef.current = true;
const startActivity = async () => {
if (Platform.OS === 'android' && Platform.Version >= 33) {
+119 -444
View File
@@ -28,65 +28,17 @@ import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { useThemeColors } from "@/lib/theme";
import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
import { resolveTrainDataIcon } from "@/lib/trainDataIcon";
import { resolveTrainIconEntries } from "@/lib/trainIconEntries";
import {
startTrainFollowActivity,
updateTrainFollowActivity,
endTrainFollowActivity,
isAvailable as isLiveActivityAvailable,
cancelLocationAnnouncements,
} 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 = {
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 }) => {
const { colors, fixed } = useThemeColors();
const {
@@ -98,39 +50,19 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
setFixedPositionSize,
} = useCurrentTrain();
const { mapSwitch, iconSetting, playbackCurrentTimeIso } = useTrainMenu();
const { allCustomTrainData, allTrainDiagram, getTodayOperationByTrainId } =
useAllTrainDiagram();
const { mapSwitch, iconSetting } = useTrainMenu();
const { allCustomTrainData, allTrainDiagram } = useAllTrainDiagram();
const iconDisplayMode = normalizeIconDisplayMode(iconSetting);
const [liveNotifyId, setLiveNotifyId] = useState<string | null>(null);
const liveNotifyIdRef = useRef<string | null>(null);
const hasStartedRef = useRef(false);
const backgroundRikkaSignatureRef = useRef("");
const lastTrainPositionAnnouncementRef = useRef("");
const backgroundRikkaTrackingId = `train-${trainID}`;
const [train, setTrain] = useState<trainDataType>(null);
const [customData, setCustomData] = useState<CustomTrainData>(
getCurrentTrainData(trainID, currentTrain, allCustomTrainData)
);
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]);
const customTrainIcon = resolveTrainDataIcon(customData, iconDisplayMode);
useEffect(() => {
setCustomData(
getCurrentTrainData(trainID, currentTrain, allCustomTrainData)
@@ -157,11 +89,8 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
);
const computedTrainDataWithThrough = useMemo(() => {
const trainData =
allTrainDiagram[trainID]
?.split("#")
.filter((entry) => !isHiddenThroughPointEntry(entry)) ?? [];
if (trainData.length === 0) return [];
const trainData = allTrainDiagram[trainID]?.split("#");
if (!trainData) return [];
// 駅名ごとに駅情報を集約するマップを構築
const stationByNameMap = new Map();
@@ -207,8 +136,6 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
originalStationList[
lineListPair[stationIDPair[betweenStationLine]]
].forEach((d) => {
const isHiddenPassPoint = d.Station_JP?.startsWith(".");
if (isHiddenPassPoint) return;
if (
d.StationNumber > baseStationNumberFirst &&
d.StationNumber < baseStationNumberSecond
@@ -266,22 +193,6 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
setStopStationList(computedStopStationIDList);
}, [computedStopStationIDList]);
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(() => {
let position = getPosition(train);
@@ -316,127 +227,96 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
}
}, [train, stopStationIDList]);
const [currentPointData, setCurrentPointData] = useState<StationProps[]>([]);
const [nextStopStationData, setNextStopStationData] = useState<StationProps[]>([]);
const [untilStationData, setUntilStationData] = useState<string[]>([]);
const [nextStationData, setNextStationData] = useState<StationProps[]>([]);
const [untilStationData, setUntilStationData] = useState<StationProps[]>([]);
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(() => {
//棒線駅判定を入れて、棒線駅なら時間を見て分数がマイナスならcontinue;
const points = findReversalPoints(currentPosition, stopStationIDList);
if (!points || points.length === 0) return;
if (!points) return;
if (points.length == 0) return;
let searchCountFirst = points.findIndex((d) => d == true);
let searchCountLast = points.findLastIndex((d) => d == true);
const pointIndexes = points.reduce(
(acc: number[], isCurrent, index) => {
if (isCurrent) acc.push(index);
return acc;
},
[] as number[]
);
if (!pointIndexes.length) return;
const delayTime = train?.delay == "入線" ? 0 : train?.delay;
let additionalSkipCount = 0;
const firstMatchedIndex = pointIndexes[0];
const isCurrentStationCluster = currentPosition.length <= 1;
const delayTime =
train?.delay == "入線" ? 0 : parseInt(String(train?.delay), 10) || 0;
// 2駅間走行中の場合: ダイヤ順で後ろのインデックスが進行方向の駅
// Direction に関係なく、travel-order で大きい方が「向かう駅」
let searchStart: number;
if (currentPosition.length === 2) {
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;
}
let anchorIndex = firstMatchedIndex;
let usedTimeEstimation = false;
const shouldShowCurrentPoint = isCurrentStationCluster;
const hasIntermediateCurrentPoints = pointIndexes.length > 1;
for (
let searchCount = searchStart;
searchCount < trainDataWidhThrough.length;
searchCount++
) {
const nextPos = trainDataWidhThrough[searchCount];
if (!isCurrentStationCluster && hasIntermediateCurrentPoints) {
let upcomingTimedIndex = -1;
let lastPassedTimedIndex = -1;
if (nextPos) {
const [station, se, time] = nextPos.split(",");
for (
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;
// 通過駅はスキップ
if (se.includes("通")) {
continue;
}
upcomingTimedIndex = searchCount;
break;
// 駅に停車中(1点一致)の場合は時刻判定不要
if (searchCountFirst == searchCountLast) {
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;
}
const anchorEntry = parsedTrainPath[anchorIndex];
const currentPointName = anchorEntry?.station || "";
setCurrentPointData(
currentPointName ? getStationDataFromName(currentPointName) : []
);
const nextStopSearchStart = shouldShowCurrentPoint
? anchorIndex + 1
: anchorIndex;
const nextStopEntry = parsedTrainPath.find(
(entry, index) =>
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));
let trainList = [];
for (
let searchCount = searchCountFirst - 1;
searchCount < points.length;
searchCount++
) {
trainList.push(trainDataWidhThrough[searchCount]);
}
if (additionalSkipCount > 0) {
trainList = trainList.slice(additionalSkipCount);
setProbably(true);
} else {
setProbably(false);
}
setUntilStationData(trainList);
}, [
currentPosition,
parsedTrainPath,
playbackCurrentTimeIso,
stopStationIDList,
tick,
train?.delay,
getStationDataFromName,
]);
}, [currentPosition, trainDataWidhThrough]);
const [ToData, setToData] = useState("");
useEffect(() => {
if (customData.to_data && customData.to_data != "") {
@@ -455,9 +335,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
setStation(data);
}, [ToData]);
const lineColor =
customData.to_data_color && customData.to_data_color.length > 0
? customData.to_data_color[0]
: station.length > 0
station.length > 0
? lineColorList[station[0]?.StationNumber?.slice(0, 1)]
: "black";
//const lineColor = "red";
@@ -477,7 +355,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
(last: number, d: string, i: number) => (d ? i : last), -1
);
const filteredTrainData = trainDataWidhThrough.filter((d, idx) => {
if (!d || isHiddenThroughPointEntry(d)) return false;
if (!d) return false;
const [, se] = d.split(",");
if (!se) return true;
// 着を含み発を含まないエントリは終着駅のみ許可
@@ -529,22 +407,13 @@ 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 pos = currentStationLabel;
const pos = train?.Pos || "";
if (!pos) return 0;
// Pos は "駅名" (駅にいる時) or "駅A~駅B" (走行中) の形式
const posStations = pos.split("").map((s: string) =>
normalizeTrainPosLabel(s)
s.replace(/(下り)|(上り)|\(下り\)|\(上り\)/g, "").trim()
);
// 完全一致
const firstIdx = allStations.findIndex((s) => s.name === posStations[0]);
@@ -558,7 +427,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
})();
const nextStationIndex = (() => {
const name = nextStopStationData[0]?.Station_JP;
const name = nextStationData[0]?.Station_JP;
if (!name) return -1;
const idx = stationStops.indexOf(name);
if (idx >= 0) return idx;
@@ -583,10 +452,11 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
if (!liveNotifyId || !train) return;
const delayNum = train.delay === "入線" ? 0 : parseInt(String(train.delay)) || 0;
const delayStatus = delayNum > 0 ? `${delayNum}分遅れ` : "定刻";
const positionStatus = shouldShowCurrentLabel ? "ただいま" : "次は";
const positionStatus =
nextStationData[0]?.Station_JP === train.Pos ? "ただいま" : "次は";
updateTrainFollowActivity(liveNotifyId, {
currentStation: currentStationLabel,
nextStation: nextStopStationData[0]?.Station_JP || "",
currentStation: train.Pos || "",
nextStation: nextStationData[0]?.Station_JP || "",
delayMinutes: delayNum,
scheduledArrival: "",
trainNumber: trainID,
@@ -602,27 +472,13 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
allStations,
currentStationIndex,
}).catch(() => {});
}, [
train,
nextStopStationData,
liveNotifyId,
stationStops,
nextStationIndex,
currentStationIndex,
currentStationLabel,
shouldShowCurrentLabel,
]);
}, [train, nextStationData, liveNotifyId, stationStops, nextStationIndex, currentStationIndex]);
// バナー表示と同時にLive Activityを自動開始
// iOSのみ一時的に無効化中(Androidは有効)
useEffect(() => {
if (
!isLiveActivityAvailable() ||
hasStartedRef.current ||
!train ||
!nextStopStationData[0]
) {
return;
}
// iOSのLive Activityは無効化
if (Platform.OS === 'ios') return;
hasStartedRef.current = true;
const startActivity = async () => {
if (Platform.OS === 'android' && Platform.Version >= 33) {
@@ -633,14 +489,15 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
}
const delayNum = train?.delay === "入線" ? 0 : parseInt(String(train?.delay)) || 0;
const delayStatus = delayNum > 0 ? `${delayNum}分遅れ` : "定刻";
const positionStatus = shouldShowCurrentLabel ? "ただいま" : "次は";
const positionStatus =
nextStationData[0]?.Station_JP === train?.Pos ? "ただいま" : "次は";
try {
const id = await startTrainFollowActivity({
trainNumber: trainID,
lineName: "",
destination: ToData,
currentStation: currentStationLabel,
nextStation: nextStopStationData[0]?.Station_JP || "",
currentStation: train?.Pos || "",
nextStation: nextStationData[0]?.Station_JP || "",
delayMinutes: delayNum,
scheduledArrival: "",
trainType: customTrainType.shortName,
@@ -657,175 +514,10 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
setLiveNotifyId(id);
} catch (e) {
console.warn('[LiveNotify] start error:', e);
hasStartedRef.current = false;
}
};
startActivity();
}, [
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]);
}, [train]);
return (
<View
@@ -884,18 +576,19 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
<Text
style={{
fontSize: trainNameText.length > 4 ? 12 : 14,
fontFamily: customTrainType.fontFamily,
fontWeight: customTrainType.fontFamily ? undefined : "bold",
marginTop: customTrainType.fontFamily ? 3 : 0,
fontFamily: customTrainType.fontAvailable
? "JR-Nishi"
: undefined,
fontWeight: !customTrainType.fontAvailable
? "bold"
: undefined,
marginTop: customTrainType.fontAvailable ? 3 : 0,
color: fixed.textOnPrimary,
textAlignVertical: "center",
textAlign: "left",
}}
>
{customTrainType.shortName}
{customTrainType.fontFamily === "JR-WEST-PLUS" ? (
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
) : null}
</Text>
{customData.train_name && (
<Text
@@ -1000,7 +693,9 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
marginVertical: -1,
}}
>
{shouldShowCurrentLabel ? "ただいま" : "次は"}
{nextStationData[0]?.Station_JP == train?.Pos
? "ただいま"
: "次は"}
</Text>
{probably && (
<Text
@@ -1018,7 +713,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
)}
</View>
<StationNumberMaker
currentStation={bannerStationData}
currentStation={nextStationData}
singleSize={20}
useEach={true}
/>
@@ -1030,7 +725,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
flex: 1,
}}
>
{bannerStationData[0]?.Station_JP || "不明"}
{nextStationData[0]?.Station_JP || "不明"}
</Text>
{fixedPositionSize !== 226 && (
<View
@@ -1054,7 +749,6 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
train={train}
lineColor={lineColor}
trainDataWithThrough={untilStationData}
currentDisplayIndex={currentDisplayIndex}
isSmall={fixedPositionSize !== 226}
/>
</View>
@@ -1181,7 +875,6 @@ const CurrentPositionBox = ({
train,
lineColor,
trainDataWithThrough,
currentDisplayIndex,
isSmall,
}) => {
const { colors } = useThemeColors();
@@ -1191,13 +884,13 @@ const CurrentPositionBox = ({
const { isBetween, Pos: PosData } = trainPosition(train);
if (isBetween === true) {
const { from, to } = PosData;
firstText = normalizeTrainPosLabel(from);
secondText = normalizeTrainPosLabel(to);
firstText = from;
secondText = to;
marginText = "→";
} else {
const { Pos } = PosData;
if (Pos !== "") {
firstText = normalizeTrainPosLabel(Pos);
firstText = Pos;
}
}
const delayTime = train?.delay == "入線" ? 0 : parseInt(train?.delay);
@@ -1250,7 +943,6 @@ const CurrentPositionBox = ({
(() => {
// 着→発ペアを同一駅で統合(EachStopListと同様)
const merged: { d: string; arrivalTime: string | null }[] = [];
let mergedCurrentDisplayIndex = Math.max(currentDisplayIndex, 0);
for (let i = 0; i < trainDataWithThrough.length; i++) {
const d = trainDataWithThrough[i];
if (!d) continue;
@@ -1271,17 +963,11 @@ const CurrentPositionBox = ({
const [prevSt, prevSe, prevTime] = prev.split(",");
if (prevSt === st && prevSe?.includes("着")) {
merged.push({ d, arrivalTime: prevTime });
if (i === currentDisplayIndex || i - 1 === currentDisplayIndex) {
mergedCurrentDisplayIndex = merged.length - 1;
}
continue;
}
}
}
merged.push({ d, arrivalTime: null });
if (i === currentDisplayIndex) {
mergedCurrentDisplayIndex = merged.length - 1;
}
}
return merged.map(({ d, arrivalTime }, index) => (
<EachStopData
@@ -1291,7 +977,6 @@ const CurrentPositionBox = ({
delayTime={delayTime}
isSmall={isSmall}
secondText={secondText}
currentDisplayIndex={mergedCurrentDisplayIndex}
arrivalTime={arrivalTime}
/>
));
@@ -1307,28 +992,18 @@ type eachStopType = {
isSmall: boolean;
index: number;
secondText: string;
currentDisplayIndex: number;
arrivalTime?: string | null;
};
const EachStopData: FC<eachStopType> = (props) => {
const { colors } = useThemeColors();
const { playbackCurrentTimeIso } = useTrainMenu();
const {
d,
delayTime,
isSmall,
index,
secondText,
currentDisplayIndex,
arrivalTime,
} = props;
const { d, delayTime, isSmall, index, secondText, arrivalTime } = props;
if (!d) return null;
if (d == "") return null;
const [station, se, time] = d.split(",");
const calcMinute = (t: string) => {
if (!t || t === "") return null;
const now = playbackCurrentTimeIso ? dayjs(playbackCurrentTimeIso) : dayjs();
const now = dayjs();
const hour = parseInt(t.split(":")[0]);
const dt = now
.hour(hour < 4 ? hour + 24 : hour)
@@ -1403,7 +1078,7 @@ const EachStopData: FC<eachStopType> = (props) => {
style={{
fontSize: isSmall ? 8 : 14,
color:
index === currentDisplayIndex && secondText === ""
index == 1 && secondText == ""
? "#ffe852ff"
: se.includes("通")
? "#020202ff"
@@ -1413,14 +1088,14 @@ const EachStopData: FC<eachStopType> = (props) => {
fontWeight: "bold",
}}
>
{index === currentDisplayIndex && secondText === ""
{index == 1 && secondText == ""
? "→"
: se.includes("通")
? null
: "●"}
</Text>
</View>
{index === 0 && secondText !== "" && (
{index == 0 && secondText != "" && (
<View
style={{
flexDirection: "column",
@@ -1,7 +1,6 @@
import { useState, useEffect } from "react";
import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
import { useStationList } from "@/stateBox/useStationList";
import { OriginalStationList } from "@/lib/CommonTypes";
interface StationIDPair {
[key: string]: string;
@@ -11,10 +10,12 @@ interface LineListPair {
[key: string]: string;
}
const isHiddenThroughPointEntry = (raw: string) => {
const [station = "", se = ""] = (raw || "").split(",");
return station.startsWith(".") && se.includes("通");
};
interface OriginalStationList {
[key: string]: Array<{
Station_JP: string;
StationNumber: string;
}>;
}
/**
* 通過駅を含む列車データを生成するカスタムフック
@@ -32,11 +33,8 @@ export const useTrainDataWithThrough = (
const [trainDataWithThrough, setTrainDataWithThrough] = useState<string[]>([]);
useEffect(() => {
const trainData =
allTrainDiagram[trainID]
?.split("#")
.filter((entry) => !isHiddenThroughPointEntry(entry)) ?? [];
if (trainData.length === 0) return;
const trainData = allTrainDiagram[trainID]?.split("#");
if (!trainData) return;
// 各停車駅の情報を取得
const stopStationList = trainData.map((i) => {
@@ -78,8 +76,6 @@ export const useTrainDataWithThrough = (
if (!lineStations) return [];
lineStations.forEach((d) => {
const isHiddenPassPoint = d.Station_JP?.startsWith(".");
if (isHiddenPassPoint) return;
// 順方向の判定
if (
d.StationNumber > baseStationNumberFirst &&
+1 -15
View File
@@ -55,7 +55,7 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
data,
});
};
const { remountKey, remount, processHandlers, pingHandlers, webViewRef } = useWebViewRemount({
const { remountKey, processHandlers, pingHandlers, webViewRef } = useWebViewRemount({
pingEnabled: Platform.OS === "ios",
backgroundThresholdMs: null,
isFocused,
@@ -83,20 +83,6 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
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;
+6 -215
View File
@@ -1,11 +1,5 @@
import React, { useCallback, useEffect, useState } from "react";
import {
View,
Text,
ScrollView,
Platform,
TouchableOpacity,
} from "react-native";
import { View, Text, ScrollView, Platform } from "react-native";
import { Switch } from "@rneui/themed";
import { useNavigation } from "@react-navigation/native";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
@@ -15,26 +9,13 @@ import { useThemeColors } from "@/lib/theme";
import { Asset } from "expo-asset";
import { useAudioPlayer, setAudioModeAsync } 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");
export const SoundSettings = () => {
const { goBack } = useNavigation();
const { colors, fixed } = useThemeColors();
const { restrictedSoundPermission } = useTrainMenu();
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 に渡す
const [resolvedSource, setResolvedSource] = useState<AudioSource>(null);
@@ -73,34 +54,11 @@ export const SoundSettings = () => {
const previewPlayer = useAudioPlayer(resolvedSource);
useEffect(() => {
Promise.all([
AS.getItem(STORAGE_KEYS.SOUND_DELAY_ANNOUNCEMENT).catch(() => "false"),
AS.getItem(STORAGE_KEYS.VOICEPEAK_ENABLED).catch(() => "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
);
}
);
AS.getItem(STORAGE_KEYS.SOUND_DELAY_ANNOUNCEMENT)
.then((v) => setDelayAnnouncement(v === true || v === "true"))
.catch(() => {
// 未設定時はデフォルト値 false のまま
});
}, []);
const playPreview = useCallback(async () => {
@@ -127,26 +85,6 @@ 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 (
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
<SheetHeaderItem
@@ -174,153 +112,6 @@ export const SoundSettings = () => {
color={fixed.primary}
/>
</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>
</View>
);
@@ -1,214 +0,0 @@
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>
);
};
@@ -1,373 +0,0 @@
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>
);
};
+8 -11
View File
@@ -59,7 +59,7 @@ export const ListViewItem: FC<{
const [
typeString,
trainName,
fontFamily,
fontAvailable,
isOneMan,
infogram,
priority,
@@ -79,7 +79,7 @@ export const ListViewItem: FC<{
to_data_color,
train_number_override,
} = customTrainDataDetector(d.trainNumber, allCustomTrainData);
const [typeString, fontFamily, isOneMan] = getStringConfig(
const [typeString, fontAvailable, isOneMan] = getStringConfig(
type,
d.trainNumber
);
@@ -111,7 +111,7 @@ export const ListViewItem: FC<{
return [
typeString,
displayName,
fontFamily,
fontAvailable,
isOneMan,
infogram,
priority,
@@ -128,7 +128,7 @@ export const ListViewItem: FC<{
return [
typeString,
"",
fontFamily,
fontAvailable,
isOneMan,
infogram,
priority,
@@ -145,7 +145,7 @@ export const ListViewItem: FC<{
migrateTrainName(
trainDataArray[trainDataArray.length - 1].split(",")[0] + "行き"
),
fontFamily,
fontAvailable,
isOneMan,
infogram,
priority,
@@ -314,18 +314,15 @@ export const ListViewItem: FC<{
<Text
style={{
fontSize: 15,
fontFamily,
fontWeight: fontFamily ? undefined : "bold",
paddingTop: fontFamily ? 2 : 0,
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
fontWeight: !fontAvailable ? "bold" : undefined,
paddingTop: fontAvailable ? 2 : 0,
paddingLeft: 10,
color: isCancelled ? "gray" : color,
textDecorationLine: isCancelled ? "line-through" : "none",
}}
>
{typeString}
{fontFamily === "JR-WEST-PLUS" ? (
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
) : null}
</Text>
{showVehicle
? (() => {
+223 -7
View File
@@ -1,9 +1,225 @@
import type { StyleProp, ViewStyle } from "react-native";
type TrainMenuProps = {
style?: StyleProp<ViewStyle>;
};
export default function TrainMenu(_props: TrainMenuProps) {
import React, { useRef, useState, useEffect, useLayoutEffect } from "react";
import { View, Text, TouchableOpacity, Linking } from "react-native";
//import MapView from "react-native-maps";
import { useCurrentTrain } from "../stateBox/useCurrentTrain";
import { useNavigation } from "@react-navigation/native";
import lineColorList from "../assets/originData/lineColorList";
import { lineListPair, stationIDPair } from "../lib/getStationList";
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;
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>
);
}
+2 -4
View File
@@ -28,7 +28,6 @@ import { getCurrentTrainData } from "@/lib/getCurrentTrainData";
import type { NavigateFunction } from "@/types";
import { PlatformNumber } from "./LED_inside_Component/PlatformNumber";
import { useThemeColors } from "@/lib/theme";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
type Props = {
d: eachTrainDiagramType;
@@ -49,7 +48,6 @@ export const EachData: FC<Props> = (props) => {
} = props;
const { currentTrain } = useCurrentTrain();
const { stationList } = useStationList();
const { playbackCurrentTimeIso } = useTrainMenu();
const { allCustomTrainData } = useAllTrainDiagram();
const { fixed } = useThemeColors();
const openTrainInfo = (d: {
@@ -136,7 +134,7 @@ export const EachData: FC<Props> = (props) => {
const [h, m] = d.time.split(":");
const IntH = parseInt(h);
const IntM = parseInt(m);
const currentTime = playbackCurrentTimeIso ? dayjs(playbackCurrentTimeIso) : dayjs();
const currentTime = dayjs();
const trainTime = currentTime
.set("hour", IntH < 4 ? IntH + 24 : IntH)
.set("minute", IntM);
@@ -147,7 +145,7 @@ export const EachData: FC<Props> = (props) => {
setIsDepartureNow(false);
setIsShow(true);
};
}, [d.time, currentTrainData, playbackCurrentTimeIso]);
}, [d.time, currentTrainData]);
useInterval(() => {
if (isDepartureNow) {
setIsShow(!isShow);
+7 -446
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, FC, useCallback, useRef } from "react";
import { View, useWindowDimensions, Text, Platform } from "react-native";
import React, { useState, useEffect, FC } from "react";
import { View, useWindowDimensions, Text } from "react-native";
import { objectIsEmpty } from "@/lib/objectIsEmpty";
import { useCurrentTrain } from "@/stateBox/useCurrentTrain";
import { useAreaInfo } from "@/stateBox/useAreaInfo";
@@ -13,32 +13,7 @@ import { getTime, trainTimeFiltering } from "@/lib/trainTimeFiltering";
import { eachTrainDiagramType, StationProps } from "@/lib/CommonTypes";
import { useNavigation } from "@react-navigation/native";
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 { 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 {
@@ -80,38 +55,11 @@ const readBooleanSetting = async (key: string) => {
type props = {
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) => {
const { station } = props;
const { navigate, addListener } = useNavigation();
const { navigate } = useNavigation();
const { currentTrain } = useCurrentTrain();
const { stationList } = useStationList();
const { playbackCurrentTimeIso } = useTrainMenu();
const [stationDiagram, setStationDiagram] = useState<{
[key: string]: string;
}>({}); //当該駅の全時刻表
@@ -120,45 +68,10 @@ export const LED_vision: FC<props> = (props) => {
const [trainDescriptionSwitch, setTrainDescriptionSwitch] = useState(false);
const [isInfoArea, setIsInfoArea] = useState(false);
const { areaInfo, areaStationID } = useAreaInfo();
const { allTrainDiagram, allCustomTrainData } = useAllTrainDiagram();
const { allTrainDiagram } = useAllTrainDiagram();
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(() => {
isVoicepeakMountedRef.current = true;
void Promise.all([
readBooleanSetting("LEDSettings/trainIDSwitch"),
readBooleanSetting("LEDSettings/trainDescriptionSwitch"),
@@ -168,27 +81,7 @@ export const LED_vision: FC<props> = (props) => {
setTrainDescriptionSwitch(nextTrainDescriptionSwitch);
setFinalSwitch(nextFinalSwitch);
});
refreshVoicepeakSettings();
const unsubscribe = addListener("focus", refreshVoicepeakSettings);
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(() => {
// 現在の駅に停車するダイヤを作成する副作用[列車ダイヤと現在駅情報]
@@ -228,316 +121,10 @@ export const LED_vision: FC<props> = (props) => {
if (!currentTrain) return () => {};
const data = trainTimeAndNumber
.filter((d) => currentTrain.map((m) => m.num).includes(d.train)) //現在の列車に絞る[ToDo]
.filter((d) => trainTimeFiltering({ d, currentTrain, station, stationList, now: playbackCurrentTimeIso })) //時間フィルター
.filter((d) => trainTimeFiltering({ d, currentTrain, station })) //時間フィルター
.filter((d) => !!finalSwitch || d.lastStation != station[0].Station_JP); //最終列車表示設定
setSelectedTrain(data);
}, [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);
}, [trainTimeAndNumber, currentTrain, finalSwitch]);
const { width } = useWindowDimensions();
const adjustedWidth = width * 0.98;
@@ -551,32 +138,6 @@ export const LED_vision: FC<props> = (props) => {
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]} />
<View
-3
View File
@@ -10,9 +10,6 @@ export const API_ENDPOINTS = {
/** 本日のダイアグラムデータ(experimental環境用) */
DIAGRAM_TODAY_BETA: `${BASE_URL}/tmp/diagram-today-beta.json`,
/** JR四国運行情報スナップショット */
OPERATION_INFO: `${BASE_URL}/operation-info/jr-shikoku/latest.json`,
/** カスタム列車データ */
CUSTOM_TRAIN_DATA: 'https://haruk.in/api/jr/getTrain.php',
-18
View File
@@ -109,24 +109,6 @@ export const STORAGE_KEYS = {
/** 駅固定モード遅延速報案内機能(サウンド) */
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") */
COLOR_THEME: 'colorTheme',
@@ -1,591 +0,0 @@
# 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
@@ -1,522 +0,0 @@
# バックグラウンドりっかちゃん通知・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)
File diff suppressed because it is too large Load Diff
@@ -1,40 +0,0 @@
# operation-info `areaInfo` 観察メモ
更新日: 2026-07-30
## 方針
`latest.json``compatibility.areaInfo` は、現時点で値域をアプリ側から
過度に固定しない。実際の運行情報発生時・正常復帰時・公式ページ変更時のデータを
観察し、確認できた挙動に合わせて順次型定義と判定処理を調整する。
当面は次の方針を維持する。
- 路線要素の `status``boolean` として扱う。
- `area === "genelic"``status` は公式HTML由来の文字列として扱う。
- 未知の `area` は無理に駅IDへ変換せず、安全に読み飛ばす。
- `OperationInfoAreaState.status` は、観察が進むまで `boolean | string` を維持する。
- automation側・アプリ側の値域を推測だけで狭めない。
## 現時点で確認できている値
- 公開中の正常時JSON: 路線は `false``genelic``"nodelay"`
- automation側の異常時fixture: 影響路線は `true``genelic``"delay"`
- automation側の正常時fixtureには、`genelic``"normal"` になる入力もある。
- automationは `genelic.status` を正規化せず、公式HTMLのclass文字列を格納する。
## 観察項目
実データで運行情報が発生・更新・解除された際は、次を確認する。
1. `status``compatibility.hasOperationInfo` が一致するか。
2. `operationInfoText` が空でないとき、影響路線の `status` がどうなるか。
3. 予告・参考情報など、本文はあるが影響路線がないケースが存在するか。
4. `genelic.status``"nodelay"` / `"delay"` 以外の値が現れるか。
5. 既知10路線以外の `area` が追加されるか。
6. 正常復帰時に、本文・路線フラグ・`genelic.status` が同じ更新で切り替わるか。
観察時は最低限、`fetchedAt``contentHash``stateHash`
`compatibility` 全体を記録する。個別事例が集まった段階で、
`hasOperationInfo` を中心とした判定への整理、discriminated union化、
runtime validation追加を再検討する。
@@ -1,232 +0,0 @@
# アプリ向け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. 公開予定日と、アプリ側が切り替えてよいタイミング
@@ -1,153 +0,0 @@
# 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
@@ -72,9 +72,6 @@ export type CustomTrainData = {
isThrough: boolean;
platformNum: string | null;
se?: string;
isOrigin?: boolean;
arrivalTime?: string;
departureTime?: string;
};
export type StationProps = {
@@ -92,9 +89,6 @@ export type CustomTrainData = {
lng: number;
isSpot?: boolean;
};
export type OriginalStationList = Record<string, StationProps[]>;
export type OperationLogs = {
id: number;
operation_id?: string;
-176
View File
@@ -1,176 +0,0 @@
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 };
};
+39 -2
View File
@@ -2,8 +2,6 @@
// Source: https://github.com/micolous/metrodroid (GPL-3.0)
// Keys: stationId = ((areaCode)<<16) | ((lineCode)<<8) | stationCode
// 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 }
@@ -63,6 +61,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x000147: { s: '清水', l: '東海道本', c: '東海旅客鉄道' },
0x000149: { s: '草薙', l: '東海道本', c: '東海旅客鉄道' },
0x00014b: { s: '東静岡', l: '東海道本', c: '東海旅客鉄道' },
0x00014b: { s: '東静岡', l: '東海道本', c: '東海旅客鉄道' },
0x00014c: { s: '静岡', l: '東海道本', c: '東海旅客鉄道' },
0x00014e: { s: '安倍川', l: '東海道本', c: '東海旅客鉄道' },
0x00014f: { s: '用宗', l: '東海道本', c: '東海旅客鉄道' },
@@ -268,6 +267,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x000271: { s: '太子堂', l: '東北本', c: '東日本旅客鉄道' },
0x000272: { s: '長町', l: '東北本', c: '東日本旅客鉄道' },
0x000273: { s: '仙台', l: '東北本', c: '東日本旅客鉄道' },
0x000273: { s: '仙台', l: '東北本', c: '東日本旅客鉄道' },
0x000275: { s: '東仙台', l: '東北本', c: '東日本旅客鉄道' },
0x000276: { s: '岩切', l: '東北本', c: '東日本旅客鉄道' },
0x000277: { s: '陸前山王', l: '東北本', c: '東日本旅客鉄道' },
@@ -665,7 +665,9 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x00062c: { s: '大野城', l: '鹿児島本', c: '九州旅客鉄道' },
0x00062d: { s: '水城', l: '鹿児島本', c: '九州旅客鉄道' },
0x00062e: { s: '都府楼南', l: '鹿児島本', c: '九州旅客鉄道' },
0x00062f: { s: '二日市', l: '鹿児島本', c: '九州旅客鉄道' },
0x00062f: { s: '都府楼南', l: '鹿児島本', c: '九州旅客鉄道' },
0x000630: { s: '二日市', l: '鹿児島本', c: '九州旅客鉄道' },
0x000630: { s: '天拝山', l: '鹿児島本', c: '九州旅客鉄道' },
0x000631: { s: '天拝山', l: '鹿児島本', c: '九州旅客鉄道' },
0x000632: { s: '原田', l: '鹿児島本', c: '九州旅客鉄道' },
@@ -723,6 +725,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x00071a: { s: '三毛門', l: '日豊本', c: '九州旅客鉄道' },
0x00071b: { s: '吉富', l: '日豊本', c: '九州旅客鉄道' },
0x00071c: { s: '中津', l: '日豊本', c: '九州旅客鉄道' },
0x000728: { s: '中津', l: '日豊本', c: '九州旅客鉄道' },
0x000728: { s: '宇佐', l: '日豊本', c: '九州旅客鉄道' },
0x00072a: { s: '西屋敷', l: '日豊本', c: '九州旅客鉄道' },
0x00073b: { s: '別府', l: '日豊本', c: '九州旅客鉄道' },
@@ -1110,7 +1113,9 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x00104c: { s: '高野川', l: '予讃', c: '四国旅客鉄道' },
0x001058: { s: '五郎', l: '予讃', c: '四国旅客鉄道' },
0x001059: { s: '伊予大洲', l: '予讃', c: '四国旅客鉄道' },
0x001081: { s: '御免', l: 'ごめんなはり', c: '土佐くろしお鉄道' },
0x001081: { s: '後免', l: '阿佐', c: '土佐くろしお鉄道' },
0x001083: { s: '後免町', l: '阿佐', c: '土佐くろしお鉄道' },
0x001083: { s: '御免町', l: 'ごめんなはり', c: '土佐くろしお鉄道' },
0x001102: { s: '金蔵寺', l: '土讃', c: '四国旅客鉄道' },
0x001103: { s: '善通寺', l: '土讃', c: '四国旅客鉄道' },
@@ -1118,6 +1123,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x00110c: { s: '佃', l: '土讃', c: '四国旅客鉄道' },
0x00110d: { s: '阿波池田', l: '土讃', c: '四国旅客鉄道' },
0x001113: { s: '大歩危', l: '土讃', c: '四国旅客鉄道' },
0x001124: { s: '御免', l: '土讃', c: '四国旅客鉄道' },
0x001124: { s: '後免', l: '土讃', c: '四国旅客鉄道' },
0x00112a: { s: '高知', l: '土讃', c: '四国旅客鉄道' },
0x00112b: { s: '入明', l: '土讃', c: '四国旅客鉄道' },
@@ -1643,6 +1649,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x002984: { s: '岩手和井内', l: '岩泉', c: '東日本旅客鉄道' },
0x002986: { s: '押角', l: '岩泉', c: '東日本旅客鉄道' },
0x002a0c: { s: '新川崎', l: '横須賀', c: '東日本旅客鉄道' },
0x002a0d: { s: '武蔵小杉', l: '横須賀', c: '東日本旅客鉄道' },
0x002a0d: { s: '新川崎', l: '横須賀', c: '東日本旅客鉄道' },
0x002a0e: { s: '西大井', l: '横須賀', c: '東日本旅客鉄道' },
0x002a15: { s: '新日本橋', l: '総武本', c: '東日本旅客鉄道' },
@@ -1664,6 +1671,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x002ada: { s: '登別', l: '室蘭本', c: '北海道旅客鉄道' },
0x002ae0: { s: '白老', l: '室蘭本', c: '北海道旅客鉄道' },
0x002ae3: { s: '錦岡', l: '室蘭本', c: '北海道旅客鉄道' },
0x002ae3: { s: '錦岡', l: '室蘭本', c: '北海道旅客鉄道' },
0x002ae5: { s: '糸井', l: '室蘭本', c: '北海道旅客鉄道' },
0x002ae9: { s: '苫小牧', l: '室蘭本', c: '北海道旅客鉄道' },
0x002aeb: { s: '沼ノ端', l: '室蘭本', c: '北海道旅客鉄道' },
@@ -2273,6 +2281,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x00524a: { s: '三角', l: '三角', c: '九州旅客鉄道' },
0x005318: { s: '太田部', l: '小海', c: '東日本旅客鉄道' },
0x005319: { s: '中込', l: '小海', c: '東日本旅客鉄道' },
0x00531a: { s: '滑津', l: '小海', c: '東日本旅客鉄道' },
0x00531a: { s: '中込', l: '小海', c: '東日本旅客鉄道' },
0x00531b: { s: '北中込', l: '小海', c: '東日本旅客鉄道' },
0x00531c: { s: '岩村田', l: '小海', c: '東日本旅客鉄道' },
@@ -2282,6 +2291,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x005352: { s: '人吉', l: '肥薩', c: '九州旅客鉄道' },
0x00540c: { s: '戸狩野沢温泉', l: '飯山', c: '東日本旅客鉄道' },
0x005415: { s: '森宮野原', l: '飯山', c: '東日本旅客鉄道' },
0x005415: { s: '森宮野原', l: '飯山', c: '東日本旅客鉄道' },
0x00541f: { s: '十日町', l: '飯山', c: '東日本旅客鉄道' },
0x005420: { s: '魚沼中条', l: '飯山', c: '東日本旅客鉄道' },
0x005515: { s: '吉田', l: '越後', c: '東日本旅客鉄道' },
@@ -2705,6 +2715,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x007403: { s: '播磨高岡', l: '姫新', c: '西日本旅客鉄道' },
0x007404: { s: '余部', l: '姫新', c: '西日本旅客鉄道' },
0x007407: { s: '本竜野', l: '姫新', c: '西日本旅客鉄道' },
0x007408: { s: '東觜崎', l: '姫新線', c: '西日本旅客鉄道' },
0x007408: { s: '東觜崎', l: '姫新', c: '西日本旅客鉄道' },
0x007409: { s: '播磨新宮', l: '姫新', c: '西日本旅客鉄道' },
0x007410: { s: '佐用', l: '姫新', c: '西日本旅客鉄道' },
@@ -3442,6 +3453,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x00c708: { s: '京王多摩センター', l: '相模原', c: '京王電鉄' },
0x00c709: { s: '京王堀之内', l: '相模原', c: '京王電鉄' },
0x00c70a: { s: '南大沢', l: '相模原', c: '京王電鉄' },
0x00c70a: { s: '南大沢', l: '相模原', c: '京王電鉄' },
0x00c70b: { s: '多摩境', l: '相模原', c: '京王電鉄' },
0x00c70c: { s: '橋本', l: '相模原', c: '京王電鉄' },
0x00c801: { s: '北野', l: '高尾', c: '京王電鉄' },
@@ -3584,6 +3596,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x00d403: { s: '新高島', l: 'みなとみらい', c: '横浜高速鉄道' },
0x00d405: { s: 'みなとみらい', l: 'みなとみらい', c: '横浜高速鉄道' },
0x00d407: { s: '馬車道', l: 'みなとみらい', c: '横浜高速鉄道' },
0x00d409: { s: '日本大通', l: 'みなとみらい', c: '横浜高速鉄道' },
0x00d409: { s: '日本大通り', l: 'みなとみらい', c: '横浜高速鉄道' },
0x00d40b: { s: '元町・中華街', l: 'みなとみらい', c: '横浜高速鉄道' },
0x00d501: { s: '泉岳寺', l: '本', c: '京浜急行電鉄' },
@@ -3641,8 +3654,14 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x00d603: { s: '大鳥居', l: '空港', c: '京浜急行電鉄' },
0x00d604: { s: '穴守稲荷', l: '空港', c: '京浜急行電鉄' },
0x00d605: { 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: '京浜急行電鉄' },
0x00d701: { s: '京急川崎', l: '大師', c: '京浜急行電鉄' },
0x00d702: { s: '港町', l: '大師', c: '京浜急行電鉄' },
0x00d703: { s: '鈴木町', l: '大師', c: '京浜急行電鉄' },
@@ -4092,6 +4111,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x00fa07: { s: '整備場', l: '東京モノレール羽田', c: '東京モノレール' },
0x00fa08: { s: '羽田', l: '東京モノレール羽田', c: '東京モノレール' },
0x00fa09: { s: '天空橋', l: '東京モノレール羽田', c: '東京モノレール' },
0x00fa0a: { s: '国際線ターミナルビル', l: '東京モノレール羽田', c: '東京モノレール' },
0x00fa0a: { s: '羽田空港国際線ビル', l: '東京モノレール羽田', c: '東京モノレール' },
0x00fa0b: { s: '新整備場', l: '東京モノレール羽田', c: '東京モノレール' },
0x00fa0c: { s: '羽田空港第1ビル', l: '東京モノレール羽田', c: '東京モノレール' },
@@ -4803,6 +4823,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x02933e: { s: '元町', l: '本', c: '阪神電気鉄道' },
0x029401: { s: '大阪難波', l: '阪神なんば', c: '阪神電気鉄道' },
0x029403: { s: '桜川', l: '阪神なんば', c: '阪神電気鉄道' },
0x029404: { s: '桜川', l: '阪神なんば', c: '阪神電気鉄道' },
0x029404: { s: 'ドーム前', l: '阪神なんば', c: '阪神電気鉄道' },
0x029405: { s: '九条', l: '阪神なんば', c: '阪神電気鉄道' },
0x029407: { s: '西九条', l: '西大阪', c: '阪神電気鉄道' },
@@ -5101,9 +5122,12 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x02c54e: { s: '鳥羽街道', l: '京阪本', c: '京阪電気鉄道' },
0x02c550: { s: '東福寺', l: '京阪本', c: '京阪電気鉄道' },
0x02c552: { s: '七条', l: '京阪本', c: '京阪電気鉄道' },
0x02c554: { s: '五条', l: '京阪本', c: '京阪電気鉄道' },
0x02c554: { s: '清水五条', l: '京阪本', c: '京阪電気鉄道' },
0x02c556: { s: '祇園四条', l: '京阪本', c: '京阪電気鉄道' },
0x02c556: { s: '四条', l: '京阪本', c: '京阪電気鉄道' },
0x02c558: { s: '三条', l: '京阪本', c: '京阪電気鉄道' },
0x02c55a: { s: '丸太町', l: '鴨東', c: '京阪電気鉄道' },
0x02c55a: { s: '神宮丸太町', l: '鴨東', c: '京阪電気鉄道' },
0x02c55c: { s: '出町柳', l: '鴨東', c: '京阪電気鉄道' },
0x02c5c2: { s: '八幡市', l: '京阪鋼索', c: '京阪電気鉄道' },
@@ -5312,8 +5336,12 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x02e603: { s: '近鉄新庄', l: '御所', c: '近畿日本鉄道' },
0x02e604: { s: '忍海', l: '御所', c: '近畿日本鉄道' },
0x02e605: { s: '近鉄御所', l: '御所', c: '近畿日本鉄道' },
0x02e701: { s: '近鉄難波', l: '難波', c: '近畿日本鉄道' },
0x02e701: { s: '大阪難波', l: '難波', c: '近畿日本鉄道' },
0x02e701: { s: '大阪難波', l: '難波', c: '近畿日本鉄道' },
0x02e702: { s: '近鉄日本橋', l: '難波', c: '近畿日本鉄道' },
0x02e703: { s: '上本町', l: '大阪', c: '近畿日本鉄道' },
0x02e703: { s: '大阪上本町', l: '大阪', c: '近畿日本鉄道' },
0x02e703: { s: '大阪上本町', l: '大阪', c: '近畿日本鉄道' },
0x02e704: { s: '鶴橋', l: '大阪', c: '近畿日本鉄道' },
0x02e706: { s: '今里', l: '大阪', c: '近畿日本鉄道' },
@@ -5467,6 +5495,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x02ef16: { s: '桑名', l: '名古屋', c: '近畿日本鉄道' },
0x02ef17: { s: '益生', l: '名古屋', c: '近畿日本鉄道' },
0x02ef19: { s: '伊勢朝日', l: '名古屋', c: '近畿日本鉄道' },
0x02ef1b: { s: '富洲原', l: '名古屋', c: '近畿日本鉄道' },
0x02ef1b: { s: '川越富洲原', l: '名古屋', c: '近畿日本鉄道' },
0x02ef1d: { s: '近鉄富田', l: '名古屋', c: '近畿日本鉄道' },
0x02ef1f: { s: '霞ヶ浦', l: '名古屋', c: '近畿日本鉄道' },
@@ -5483,6 +5512,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x02ef2c: { s: '伊勢若松', l: '名古屋', c: '近畿日本鉄道' },
0x02ef2e: { s: '千代崎', l: '名古屋', c: '近畿日本鉄道' },
0x02ef30: { s: '白子', l: '名古屋', c: '近畿日本鉄道' },
0x02ef30: { s: '白子', l: '名古屋', c: '近畿日本鉄道' },
0x02ef31: { s: '鼓ヶ浦', l: '名古屋', c: '近畿日本鉄道' },
0x02ef33: { s: '磯山', l: '名古屋', c: '近畿日本鉄道' },
0x02ef35: { s: '千里', l: '名古屋', c: '近畿日本鉄道' },
@@ -5543,6 +5573,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x02f303: { s: '馬道 廃止', l: '北勢', c: '近畿日本鉄道' },
0x02f304: { s: '西別所 廃止', l: '北勢', c: '近畿日本鉄道' },
0x02f305: { s: '蓮花寺 廃止', l: '北勢', c: '近畿日本鉄道' },
0x02f305: { s: '蓮花寺 廃止', l: '北勢', c: '近畿日本鉄道' },
0x02f306: { s: '在良 廃止', l: '北勢', c: '近畿日本鉄道' },
0x02f307: { s: '坂井橋 廃止', l: '北勢', c: '近畿日本鉄道' },
0x02f30b: { s: '六把野 廃止', l: '北勢', c: '近畿日本鉄道' },
@@ -5598,12 +5629,15 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x02ffb7: { s: '貿易センター', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffb9: { s: 'ポートターミナル', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffbb: { s: '中公園', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffbd: { s: '市民病院前', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffbd: { s: 'みなとじま', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffbf: { s: '市民広場', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffc1: { s: '南公園', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffc3: { s: '中埠頭', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffc5: { s: '北埠頭', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffc7: { s: '先端医療センター前', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffc7: { s: '医療センター', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffc9: { s: '京コンピュータ前', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffc9: { s: 'ポートアイランド南', l: 'ポートアイランド', c: '神戸新交通' },
0x02ffcb: { s: '神戸空港', l: 'ポートアイランド', c: '神戸新交通' },
0x032841: { s: '川西', l: '錦川清流', c: '錦川鉄道' },
@@ -5688,6 +5722,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x03be19: { s: '城北', l: 'アストラムライン', c: '広島高速交通' },
0x03be1d: { s: '白島', l: 'アストラムライン', c: '広島高速交通' },
0x03be1f: { s: '牛田', l: 'アストラムライン', c: '広島高速交通' },
0x03be21: { s: '不動院前', l: 'アストラムライン', c: '広島' },
0x03be21: { s: '不動院前', l: 'アストラムライン', c: '広島高速交通' },
0x03be23: { s: '祇園新橋北', l: 'アストラムライン', c: '広島高速交通' },
0x03be25: { s: '西原', l: 'アストラムライン', c: '広島高速交通' },
@@ -5797,6 +5832,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x03d64d: { s: '西鉄千早', l: '貝塚', c: '西日本鉄道' },
0x03d64f: { s: '名島', l: '貝塚', c: '西日本鉄道' },
0x03d651: { s: '貝塚', l: '貝塚', c: '西日本鉄道' },
0x03d765: { s: '西鉄福岡(天神)', l: '天神大牟田線', c: '西日本鉄道' },
0x03d765: { s: '西鉄福岡(天神)', l: '天神大牟田', c: '西日本鉄道' },
0x03d767: { s: '薬院', l: '天神大牟田', c: '西日本鉄道' },
0x03d769: { s: '西鉄平尾', l: '天神大牟田', c: '西日本鉄道' },
@@ -5810,6 +5846,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
0x03d777: { s: '下大利', l: '天神大牟田', c: '西日本鉄道' },
0x03d779: { s: '都府楼前', l: '天神大牟田', c: '西日本鉄道' },
0x03d77b: { s: '西鉄二日市', l: '天神大牟田', c: '西日本鉄道' },
0x03d77c: { s: '二日市南', l: '天神大牟田', c: '西日本鉄道' },
0x03d77c: { s: '紫', l: '天神大牟田', c: '西日本鉄道' },
0x03d77d: { s: '朝倉街道', l: '天神大牟田', c: '西日本鉄道' },
0x03d77f: { s: '桜台', l: '天神大牟田', c: '西日本鉄道' },
+49 -14
View File
@@ -1,18 +1,53 @@
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) => {
const { shortName, fontFamily, isOneMan } = getTrainType({ type, id });
const typeString =
type === "Other" && shortName === "その他" ? "" : shortName;
return [typeString, fontFamily, isOneMan];
switch (type) {
case "Normal":
return ["普通", true, false];
case "OneMan":
return ["普通", true, true];
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];
}
};
+20 -21
View File
@@ -29,7 +29,6 @@ type trainTypeString =
| "試運転"
| "工事"
| "その他";
export type TrainTypeFontFamily = "JR-Nishi" | "JR-WEST-PLUS";
type trainTypeDataString = "rapid" | "express" | "normal" | "notService";
type getTrainType = (e: {
type: trainTypeID;
@@ -39,7 +38,7 @@ type getTrainType = (e: {
color: colorString;
name: trainTypeString;
shortName: string;
fontFamily: TrainTypeFontFamily | undefined;
fontAvailable: boolean;
isOneMan: boolean;
data: trainTypeDataString;
};
@@ -50,7 +49,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: whiteMode ? "#333333ff" : "white",
name: "普通列車",
shortName: "普通",
fontFamily: "JR-Nishi",
fontAvailable: true,
isOneMan: false,
data: "normal",
};
@@ -59,7 +58,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: whiteMode ? "#333333ff" : "white",
name: "普通列車(ワンマン)",
shortName: "普通",
fontFamily: "JR-Nishi",
fontAvailable: true,
isOneMan: true,
data: "normal",
};
@@ -68,7 +67,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: whiteMode ? "#00a0bdff" : "aqua",
name: "快速",
shortName: "快速",
fontFamily: "JR-Nishi",
fontAvailable: true,
isOneMan: false,
data: "rapid",
};
@@ -77,7 +76,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: whiteMode ? "#00a0bdff" : "aqua",
name: "快速",
shortName: "快速",
fontFamily: "JR-Nishi",
fontAvailable: true,
isOneMan: true,
data: "rapid",
};
@@ -86,7 +85,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#ee424dff",
name: "特急",
shortName: "特急",
fontFamily: "JR-Nishi",
fontAvailable: true,
isOneMan: false,
data: "express",
};
@@ -95,7 +94,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: whiteMode ? "#e000b0ff" : "pink",
name: "寝台特急",
shortName: "特急",
fontFamily: "JR-Nishi",
fontAvailable: true,
isOneMan: false,
data: "express",
};
@@ -105,7 +104,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#297bff",
name: "臨時",
shortName: "臨時",
fontFamily: "JR-Nishi",
fontAvailable: true,
isOneMan: false,
data: "normal",
};
@@ -114,7 +113,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#297bff",
name: "臨時快速",
shortName: "臨時快速",
fontFamily: "JR-Nishi",
fontAvailable: true,
isOneMan: false,
data: "normal",
};
@@ -123,7 +122,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#297bff",
name: "臨時特急",
shortName: "臨時特急",
fontFamily: "JR-Nishi",
fontAvailable: true,
isOneMan: false,
data: "normal",
};
@@ -132,7 +131,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#ff7300ff",
name: "団体",
shortName: "団体",
fontFamily: "JR-Nishi",
fontAvailable: true,
isOneMan: false,
data: "normal",
};
@@ -141,7 +140,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#007488ff",
name: "貨物",
shortName: "貨物",
fontFamily: undefined,
fontAvailable: false,
isOneMan: false,
data: "notService",
};
@@ -150,7 +149,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#5f5f5fff",
name: "回送",
shortName: "回送",
fontFamily: "JR-WEST-PLUS",
fontAvailable: false,
isOneMan: false,
data: "notService",
};
@@ -159,7 +158,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#5f5f5fff",
name: "試運転",
shortName: "試運転",
fontFamily: "JR-WEST-PLUS",
fontAvailable: false,
isOneMan: false,
data: "notService",
};
@@ -168,7 +167,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#5f5f5fff",
name: "工事",
shortName: "工事",
fontFamily: undefined,
fontAvailable: false,
isOneMan: false,
data: "notService",
};
@@ -177,7 +176,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#5f5f5fff",
name: "単機回送",
shortName: "単機回送",
fontFamily: undefined,
fontAvailable: false,
isOneMan: false,
data: "notService",
};
@@ -190,7 +189,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#5f5f5fff",
name: "単機回送",
shortName: "単機回送",
fontFamily: undefined,
fontAvailable: false,
isOneMan: false,
data: "notService",
};
@@ -203,7 +202,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#5f5f5fff",
name: "回送",
shortName: "回送",
fontFamily: "JR-WEST-PLUS",
fontAvailable: false,
isOneMan: false,
data: "notService",
};
@@ -212,7 +211,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: "#5f5f5fff",
name: "試運転",
shortName: "試運転",
fontFamily: "JR-WEST-PLUS",
fontAvailable: false,
isOneMan: false,
data: "notService",
};
@@ -222,7 +221,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
color: whiteMode ? "#333333ff" : "white",
name: "その他",
shortName: "その他",
fontFamily: undefined,
fontAvailable: false,
isOneMan: false,
data: "normal",
};
-30
View File
@@ -1,30 +0,0 @@
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)
)
);
};
-40
View File
@@ -1,40 +0,0 @@
const SOURCE_UPDATE_INTERVAL_MS = 5 * 60 * 1000;
const FETCH_WINDOW_START_MS = 10 * 1000;
const FETCH_WINDOW_END_MS = 50 * 1000;
const STALE_RETRY_MS = 30 * 1000;
const MIN_TIMER_DELAY_MS = 1000;
export const OPERATION_INFO_STALE_RETRY_MS = STALE_RETRY_MS;
/**
* R2の最終更新時刻を基準に51050
* fetchedAtが進んでいない場合は
*/
export function getNextOperationInfoFetchDelay(
fetchedAt: string,
nowMs = Date.now(),
randomValue = Math.random()
): number {
const fetchedAtMs = Date.parse(fetchedAt);
if (!Number.isFinite(fetchedAtMs)) {
return STALE_RETRY_MS;
}
const nextExpectedUpdateMs = fetchedAtMs + SOURCE_UPDATE_INTERVAL_MS;
const fetchWindowEndMs = nextExpectedUpdateMs + FETCH_WINDOW_END_MS;
const fetchWindowStartMs = Math.max(
nextExpectedUpdateMs + FETCH_WINDOW_START_MS,
nowMs + MIN_TIMER_DELAY_MS
);
if (fetchWindowStartMs >= fetchWindowEndMs) {
return STALE_RETRY_MS;
}
const clampedRandomValue = Math.min(1, Math.max(0, randomValue));
const targetMs =
fetchWindowStartMs +
(fetchWindowEndMs - fetchWindowStartMs) * clampedRandomValue;
return Math.max(MIN_TIMER_DELAY_MS, Math.round(targetMs - nowMs));
}
+12 -44
View File
@@ -1,5 +1,4 @@
import dayjs from "dayjs";
import { checkDuplicateTrainData } from "@/lib/checkDuplicateTrainData";
import { trainDataType, trainPosition } from "@/lib/trainPositionTextArray";
import { eachTrainDiagramType, StationProps } from "@/lib/CommonTypes";
import betweenData from "@/assets/originData/between";
@@ -7,15 +6,12 @@ type trainDataProps = {
d: eachTrainDiagramType;
currentTrain: trainDataType[];
station: StationProps[];
stationList: StationProps[][];
now?: string | null;
};
export const trainTimeFiltering: (x: trainDataProps) => boolean = (props) => {
const { d, currentTrain, station, stationList, now } = props;
const { d, currentTrain, station } = props;
const baseTime = 2; // 何時間以内の列車を表示するか
const currentTrainMatches = currentTrain.filter((t) => t.num == d.train);
if (currentTrainMatches.length == 0) {
const date = now ? dayjs(now) : dayjs();
if (currentTrain.filter((t) => t.num == d.train).length == 0) {
const date = dayjs();
const trainTime = date
.hour(parseInt(d.time.split(":")[0]))
.minute(parseInt(d.time.split(":")[1]));
@@ -27,10 +23,7 @@ export const trainTimeFiltering: (x: trainDataProps) => boolean = (props) => {
}
return false;
} else {
const currentTrainData =
checkDuplicateTrainData(currentTrainMatches, stationList) ??
currentTrainMatches[0];
const Pos = trainPosition(currentTrainData);
const Pos = trainPosition(currentTrain.filter((t) => t.num == d.train)[0]);
let nextPos = "";
let PrePos = "";
//
@@ -72,9 +65,9 @@ export const trainTimeFiltering: (x: trainDataProps) => boolean = (props) => {
break;
}
const [h, m] = d.time.split(":");
const delayData = currentTrainData.delay;
const delayData = currentTrain.filter((t) => t.num == d.train)[0].delay;
let delay = delayData === "入線" ? 0 : delayData;
const date = now ? dayjs(now) : dayjs();
const date = dayjs();
const IntH = parseInt(h);
const IntM = parseInt(m);
const currentHour = date.hour();
@@ -103,15 +96,6 @@ type getTimeProps = (
export const getTime: getTimeProps = (stationDiagram, station) => {
const returnData = Object.keys(stationDiagram)
.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 = {
time: "",
lastStation: "",
@@ -119,10 +103,9 @@ export const getTime: getTimeProps = (stationDiagram, station) => {
train: trainNum,
platformNum: null,
se: undefined,
arrivalTime: undefined,
departureTime: undefined,
};
diagramEntries.forEach(({ stationName, type, time, platformNum }) => {
stationDiagram[trainNum].split("#").forEach((data) => {
const [stationName, type, time, platformNum] = data.split(",");
if (!type) return;
if (type.match("着")) {
trainData.lastStation = stationName;
@@ -132,36 +115,21 @@ export const getTime: getTimeProps = (stationDiagram, station) => {
trainData.se = type;
if (type.match("発")) {
trainData.time = time;
trainData.departureTime = time;
}
if (type.match("着")) {
trainData.arrivalTime = time;
if (!trainData.departureTime) {
trainData.time = time;
}
}
if (type.match("通")) {
} else if (type.match("通")) {
trainData.time = time;
trainData.isThrough = true;
} else if (type.match("着")) {
trainData.time = time;
}
}
});
return {
train: trainNum,
time:
trainData.departureTime ||
trainData.arrivalTime ||
trainData.time,
time: trainData.time,
lastStation: trainData.lastStation,
isThrough: trainData.isThrough,
platformNum: trainData.platformNum,
se: trainData.se,
arrivalTime: trainData.arrivalTime,
departureTime: trainData.departureTime,
isOrigin:
firstTimedEntry?.stationName === station.Station_JP &&
!!trainData.departureTime &&
!trainData.arrivalTime,
};
})
.filter((d) => d.time);
-23
View File
@@ -1,23 +0,0 @@
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("")}`;
};
-807
View File
@@ -1,807 +0,0 @@
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
@@ -1,150 +0,0 @@
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
@@ -1,178 +0,0 @@
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);
});
+19 -47
View File
@@ -192,7 +192,6 @@ export const injectJavascriptData = ({
// データ変数の宣言
let stationList = {};
let trainDataList = [];
let trainDataListReady = false;
let operationList = [];
let trainDiagramData2 = {};
let probremsData = [];
@@ -260,13 +259,11 @@ export const injectJavascriptData = ({
const DatalistUpdate = () => {
fetch("${API_ENDPOINTS.TRAIN_DATA_API}").then(r => r.json())
.then(data => {
const nextTrainDataList = Array.isArray(data?.data) ? data.data : null;
if (!nextTrainDataList) return;
trainDataListReady = true;
if (!hasChanged('trainDataList', nextTrainDataList)) return;
trainDataList = nextTrainDataList;
_wcache.set('trainDataList', trainDataList);
setReload();
if (hasChanged('trainDataList', data.data)) {
trainDataList = data.data;
_wcache.set('trainDataList', trainDataList);
setReload();
}
})
.catch(() => {})
.finally(() => { setTimeout(DatalistUpdate, ${INTERVALS.DELAY_UPDATE}); });
@@ -365,12 +362,8 @@ export const injectJavascriptData = ({
Object.keys(_hashes).forEach(k => { _hashes[k] = null; });
Promise.allSettled([
fetch("${API_ENDPOINTS.TRAIN_DATA_API}").then(r => r.json()).then(data => {
if (Array.isArray(data?.data)) {
trainDataList = data.data;
trainDataListReady = true;
_hashes['trainDataList'] = JSON.stringify(trainDataList);
_wcache.set('trainDataList', trainDataList);
}
trainDataList = data.data ?? trainDataList;
_hashes['trainDataList'] = JSON.stringify(trainDataList);
}).catch(() => {}),
fetch("${API_ENDPOINTS.OPERATION_LOGS}").then(r => r.json()).then(data => {
const source = Array.isArray(data?.data) ? data.data : [];
@@ -413,7 +406,6 @@ export const injectJavascriptData = ({
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.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.unyohubData) { unyohubData = _c0.unyohubData; anyHit = true; }
if (_c0.elesiteData) { elesiteData = _c0.elesiteData; anyHit = true; }
@@ -451,9 +443,7 @@ export const injectJavascriptData = ({
// TRAIN_DATA_API: 0.84s/1MB, DIAGRAM_TODAY: 0.27s/522KB
Promise.allSettled([
fetch("${API_ENDPOINTS.TRAIN_DATA_API}").then(r => r.json()).then(data => {
if (!Array.isArray(data?.data)) return;
trainDataList = data.data;
trainDataListReady = true;
trainDataList = data.data ?? [];
_hashes['trainDataList'] = JSON.stringify(trainDataList);
_wcache.set('trainDataList', trainDataList);
}).catch(() => {}),
@@ -706,21 +696,6 @@ export const injectJavascriptData = ({
let isSeason = false;
let TrainNumberOverride;
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{
const diagram = trainDiagramData2[] || trainTimeInfo[];
if(diagram){
@@ -969,10 +944,6 @@ export const injectJavascriptData = ({
}
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%;";
@@ -1026,17 +997,12 @@ 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>";
}
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>");
.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;color:black;text-align:center;'>" + (TrainNumberOverride ? TrainNumberOverride : TrainNumber) + "</p><div style='flex:1;'></div><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%;height:13px;box-sizing:border-box;display:flex;align-items:center;justify-content:center;background:" + gradient + ";overflow:hidden;'><p style='font-size:10px;font-weight:bold;padding:0;margin:0;color:white;line-height: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 ? ToData + "行" : ToData) + "</p></div><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><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 _defTextColor = _isDark ? 'white' : 'black';
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;margin:0;color:" + _defTextColor + ";'>" + (ToData ? ToData + "行 " : "") + "</p><p style='font-size:10px;padding:0;margin:0;color:" + _defTextColor + ";'>" + (TrainNumberOverride ? TrainNumberOverride : TrainNumber) + "</p></div>");
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>"); }
.insertAdjacentHTML('beforebegin', "<p style='font-size:10px;font-weight:bold;padding: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', "<p style='font-size:10px;font-weight:bold;padding:0;color: "+(hasProblem ? "red": _defTextColor)+"; "+(hasProblem ? "animation:_jrs_blink 3s linear infinite;" : "")+"'>" + (hasProblem ? "‼️停止中‼️" : "") + "</p>");
`}
}
`;
@@ -1056,7 +1022,13 @@ const setNewTrainItem = (element,hasProblem,type)=>{
element.style.backgroundColor = _bgc;
}else{
element.style.backgroundColor = '#ffffffff';
element.style.borderColor = _isDark ? '#30363d' : 'black';
if(element.getAttribute('offclick').includes("express")){
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.borderStyle = 'solid';
+1 -1
View File
@@ -517,7 +517,7 @@ export const Menu: FC<props> = (props) => {
/>
)}
{Object.keys(originalStationList).length !== 0 && isHeavyContentReady && (
{originalStationList.length != 0 && isHeavyContentReady && (
<>
<CarouselBox
{...{
@@ -29,8 +29,10 @@ class LiveActivityForegroundService : Service() {
const val NOTIFICATION_ID = 8001
private const val TAG = "LiveActivityService"
private const val POLL_INTERVAL_MS = 15_000L
private const val POSITION_API_URL =
"https://jr-shikoku-api-data-storage.haruk.in/tmp/currentPositions.json"
private const val PRIMARY_API_URL =
"https://n8n.haruk.in/webhook/c501550c-7d1b-4e50-927b-4429fe18931a"
private const val FALLBACK_API_URL =
"https://script.google.com/macros/s/AKfycby9Y2-Bm75J_WkbZimi7iS8v5r9wMa9wtzpdwES9sOGF4i6HIYEJOM60W6gM1gXzt1o/exec"
@Volatile
var isRunning = false
@@ -221,9 +223,9 @@ class LiveActivityForegroundService : Service() {
private fun pollTrainPosition() {
if (trainNumber.isEmpty()) return
try {
val json = fetchApi(POSITION_API_URL)
val json = fetchApi(PRIMARY_API_URL) ?: fetchApi(FALLBACK_API_URL)
if (json == null) {
Log.w(TAG, "Position API failed")
Log.w(TAG, "Both APIs failed")
return
}
@@ -328,7 +330,7 @@ class LiveActivityForegroundService : Service() {
*/
private fun pollStationTrains() {
try {
val json = fetchApi(POSITION_API_URL) ?: return
val json = fetchApi(PRIMARY_API_URL) ?: fetchApi(FALLBACK_API_URL) ?: return
val allTrains = parseAllTrains(json)
if (trainsJson == "[]" || trainsJson.isEmpty()) return
val trains = try { JSONArray(trainsJson) } catch (_: Exception) { return }
@@ -16,7 +16,7 @@ Pod::Spec.new do |s|
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.frameworks = 'ActivityKit', 'CoreLocation', 'UserNotifications'
s.frameworks = 'ActivityKit'
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
@@ -196,35 +196,6 @@ public class ExpoLiveActivityModule: Module {
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
AsyncFunction("endAllActivities") { (promise: Promise) in
@@ -1,193 +0,0 @@
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)-"
}
}
+3 -43
View File
@@ -122,15 +122,6 @@ export interface StationLockState {
trains?: StationTrainInfo[];
}
export interface LocationAnnouncement {
identifier: string;
stationName: string;
latitude: number;
longitude: number;
radiusMeters: number;
soundFileName: string;
}
export interface StationTrainInfo {
time: string;
typeName: string;
@@ -252,9 +243,11 @@ if (ExpoLiveActivityModule) {
* iOS 16.2+ true
* Android true
*
* NOTE: 一時的に無効化中 false
*/
export function isAvailable(): boolean {
return ExpoLiveActivityModule?.isAvailable() ?? false;
return false;
// return ExpoLiveActivityModule?.isAvailable() ?? false;
}
/**
@@ -429,39 +422,6 @@ export function getActiveStationLockActivities(): string[] {
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
/**
+32 -847
View File
@@ -247,17 +247,10 @@ const buildOperationPageScript = (layout: {
'.jrs-capture-page-link:active{opacity:.9 !important;transform:translateY(1px) !important;}',
'.jrs-capture-page-link.is-secondary{background:#ffffff !important;color:#0076a8 !important;border:1px solid #0099CB !important;box-shadow:none !important;}',
'.jrs-capture-page-link.is-secondary:visited{color:#0076a8 !important;}',
'.jrs-capture-page-link.is-x{background:linear-gradient(135deg,#111827 0%,#0f76a8 100%) !important;color:#fff !important;border:none !important;box-shadow:0 6px 14px rgba(15,118,168,.24) !important;}',
'.jrs-capture-page-link.is-x:visited{color:#fff !important;}',
'.jrs-capture-page-link.is-disabled,.jrs-capture-link.is-disabled,.jrs-subcapture-link.is-disabled{pointer-events:none !important;opacity:.52 !important;transform:none !important;}',
'.jrs-capture-wrap{display:flex !important;flex-wrap:wrap !important;justify-content:flex-end !important;gap:8px !important;margin:12px 0 8px !important;}',
'.jrs-capture-wrap{display:block !important;text-align:right !important;margin:12px 0 8px !important;}',
'.jrs-capture-link{display:inline-block !important;background:#0a84ff !important;color:#fff !important;padding:10px 14px !important;border-radius:999px !important;font-size:12px !important;font-weight:700 !important;line-height:1.2 !important;text-decoration:none !important;box-shadow:0 4px 12px rgba(10,132,255,.25) !important;position:relative !important;z-index:9999 !important;}',
'.jrs-capture-link:visited{color:#fff !important;}',
'.jrs-capture-link:active{opacity:.9 !important;transform:translateY(1px) !important;}',
'.jrs-capture-link.is-secondary{background:#ffffff !important;color:#0076a8 !important;border:1px solid #0099CB !important;box-shadow:none !important;}',
'.jrs-capture-link.is-secondary:visited{color:#0076a8 !important;}',
'.jrs-capture-link.is-x{background:linear-gradient(135deg,#111827 0%,#0f76a8 100%) !important;color:#fff !important;border:none !important;box-shadow:0 6px 14px rgba(15,118,168,.24) !important;}',
'.jrs-capture-link.is-x:visited{color:#fff !important;}',
'.jrs-capture-link-debug{outline:2px solid red !important;}',
'.jrs-subcapture-wrap{display:block !important;text-align:right !important;margin:8px 0 10px !important;}',
'.jrs-subcapture-link{display:inline-block !important;background:#ffffff !important;color:#0076a8 !important;border:1px solid #0099CB !important;padding:7px 11px !important;border-radius:999px !important;font-size:11px !important;font-weight:700 !important;line-height:1.2 !important;text-decoration:none !important;position:relative !important;z-index:9999 !important;}',
@@ -401,38 +394,8 @@ const buildOperationPageScript = (layout: {
}
function postMessage(payload) {
if (!window.ReactNativeWebView) return;
payload.type = 'operationInfoCapture';
window.ReactNativeWebView.postMessage(JSON.stringify(payload));
}
function setCaptureButtonsBusy(isBusy) {
qa('.jrs-capture-page-link, .jrs-capture-link, .jrs-subcapture-link').forEach(function(element) {
if (!element || !element.classList) return;
element.classList[isBusy ? 'add' : 'remove']('is-disabled');
element.setAttribute('aria-disabled', isBusy ? 'true' : 'false');
});
}
function runCaptureAction(action) {
if (window.__jrCaptureBusy) {
return;
}
window.__jrCaptureBusy = true;
setCaptureButtonsBusy(true);
Promise.resolve()
.then(action)
.catch(function() {
postMessage({ error: true });
})
.then(function() {
window.setTimeout(function() {
window.__jrCaptureBusy = false;
setCaptureButtonsBusy(false);
}, 120);
});
postNativeMessage(payload);
}
function parseDetailBlocks(html) {
@@ -697,734 +660,6 @@ const buildOperationPageScript = (layout: {
});
}
var X_CAPTURE_PAGE_WIDTH = 1080;
var X_CAPTURE_PAGE_HEIGHT = 1440;
var X_CAPTURE_SAFE_X = 80;
var X_CAPTURE_HEADER_TOP = 76;
var X_CAPTURE_CONTENT_TOP = 208;
var X_CAPTURE_CONTENT_BOTTOM = 1218;
var X_CAPTURE_FOOTER_Y = 1262;
var X_CAPTURE_ITEM_GAP = 26;
var X_CAPTURE_UNIT_GAP = 18;
function getXCaptureNotice() {
return 'この画像は非公式アプリで生成したものです。最新情報・詳細はJR四国公式の運行情報をご確認ください。';
}
function getXPageNumberText(pageIndex, totalPages) {
return String(pageIndex + 1) + '/' + String(totalPages);
}
function parseUpdatedAtValue(value) {
var text = strip(value);
if (!text) return null;
var normalized = text
.replace(/更新日時[:]?/g, '')
.replace(/年/g, '/')
.replace(/月/g, '/')
.replace(/日/g, '')
.replace(/時/g, ':')
.replace(/分/g, '')
.replace(/\./g, '/')
.replace(/-/g, '/')
.trim();
var parsed = Date.parse(normalized);
return isNaN(parsed) ? null : parsed;
}
function getLatestUpdatedAt(items) {
var bestText = '';
var bestValue = null;
(items || []).forEach(function(item) {
var updatedAt = strip(item && item.updatedAt);
if (!updatedAt) return;
var parsed = parseUpdatedAtValue(updatedAt);
if (parsed == null) {
if (!bestText) bestText = updatedAt;
return;
}
if (bestValue == null || parsed > bestValue) {
bestValue = parsed;
bestText = updatedAt;
}
});
return bestText;
}
function measureXHeroHeader(ctx, item, contentWidth) {
var textWidth = contentWidth - 64;
ctx.font = "800 64px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
var titleLines = wrapText(ctx, strip(item.title) || '運行情報', textWidth).slice(0, 4);
ctx.font = "700 30px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
var subTitleLines = strip(item.subTitle) ? wrapText(ctx, item.subTitle, textWidth) : [];
ctx.font = "500 24px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
var updatedLines = strip(item.updatedAt) ? wrapText(ctx, item.updatedAt, textWidth) : [];
var leadLines = [];
var height = 44 + titleLines.length * 70 + (subTitleLines.length ? 14 + subTitleLines.length * 36 : 0) + (updatedLines.length ? 14 + updatedLines.length * 30 : 0) + (leadLines.length ? 18 + leadLines.length * 34 : 0) + 28;
return {
titleLines: titleLines,
subTitleLines: subTitleLines,
updatedLines: updatedLines,
leadLines: leadLines,
height: Math.max(260, Math.min(height, 420))
};
}
function measureXDetailHeader(ctx, item, contentWidth, continued) {
var textWidth = contentWidth - 54;
ctx.font = "800 38px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
var titleLines = wrapText(ctx, strip(item.title) || '運行情報', textWidth);
ctx.font = "700 26px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
var subTitleLines = strip(item.subTitle) ? wrapText(ctx, item.subTitle, textWidth) : [];
ctx.font = "500 22px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
var updatedLines = strip(item.updatedAt) ? wrapText(ctx, item.updatedAt, textWidth) : [];
var height = 30 + titleLines.length * 44 + (subTitleLines.length ? 10 + subTitleLines.length * 32 : 0) + (updatedLines.length ? 12 + updatedLines.length * 28 : 0) + 24;
return {
titleLines: titleLines,
subTitleLines: subTitleLines,
updatedLines: updatedLines,
continued: !!continued,
height: Math.max(118, height)
};
}
function createXDetailUnits(ctx, item, bodyWidth) {
var units = [];
var sections = [];
var currentSection = { heading: '', body: [] };
var blocks = item && item.blocks ? item.blocks : [];
blocks.forEach(function(block) {
if (block.type === 'badge') {
if (currentSection.heading || currentSection.body.length) {
sections.push(currentSection);
}
currentSection = { heading: strip(block.text), body: [] };
return;
}
var bodyText = strip(block.text);
if (bodyText) currentSection.body.push(bodyText);
});
if (currentSection.heading || currentSection.body.length) {
sections.push(currentSection);
}
sections.forEach(function(section) {
ctx.font = "700 28px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
var headingLines = section.heading ? wrapText(ctx, section.heading, bodyWidth - 24) : [];
ctx.font = "400 28px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
var bodyLines = [];
section.body.forEach(function(bodyText) {
bodyLines = bodyLines.concat(wrapText(ctx, bodyText, bodyWidth));
});
if (!bodyLines.length && !headingLines.length) return;
units.push({
headingLines: headingLines,
bodyLines: bodyLines,
lineHeight: 39
});
});
if (!units.length) {
ctx.font = "400 28px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
units.push({
headingLines: [],
bodyLines: wrapText(ctx, '詳細情報はJR四国公式の運行情報をご確認ください。', bodyWidth),
lineHeight: 39
});
}
return units;
}
function cloneXDetailUnits(units) {
return (units || []).map(function(unit) {
return {
headingLines: (unit.headingLines || []).slice(),
bodyLines: (unit.bodyLines || []).slice(),
lineHeight: unit.lineHeight
};
});
}
function createXOverflowNoticeUnit(ctx, bodyWidth) {
ctx.font = "700 28px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
var headingLines = wrapText(ctx, '続きの情報', bodyWidth - 24);
ctx.font = "400 28px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
return {
headingLines: headingLines,
bodyLines: wrapText(ctx, '4枚に収まらない情報があります。続きと最新情報はJR四国公式の運行情報をご確認ください。', bodyWidth),
lineHeight: 39
};
}
function getXUnitHeight(unit) {
var headingHeight = unit.headingLines && unit.headingLines.length ? Math.max(56, unit.headingLines.length * 35 + 20) : 0;
var bodyHeight = Math.max(unit.bodyLines.length, 1) * unit.lineHeight;
return 22 + headingHeight + (headingHeight ? 14 : 0) + bodyHeight + 18;
}
function buildXUnitChunk(unit, availableHeight) {
var fullHeight = getXUnitHeight(unit);
if (fullHeight <= availableHeight) {
return {
chunk: {
headingLines: unit.headingLines.slice(),
bodyLines: unit.bodyLines.slice(),
lineHeight: unit.lineHeight,
height: fullHeight
},
consumed: true,
rest: null,
height: fullHeight
};
}
var headingHeight = unit.headingLines && unit.headingLines.length ? Math.max(56, unit.headingLines.length * 35 + 20) : 0;
var availableForBody = availableHeight - 22 - headingHeight - (headingHeight ? 14 : 0) - 18;
var lineCapacity = Math.floor(availableForBody / unit.lineHeight);
var minimumLines = headingHeight ? 2 : 3;
if (lineCapacity < minimumLines) {
return null;
}
var firstLines = unit.bodyLines.slice(0, lineCapacity);
if (!firstLines.length) {
return null;
}
var restLines = unit.bodyLines.slice(lineCapacity);
var chunk = {
headingLines: unit.headingLines.slice(),
bodyLines: firstLines,
lineHeight: unit.lineHeight
};
chunk.height = getXUnitHeight(chunk);
return {
chunk: chunk,
consumed: restLines.length === 0,
rest: restLines.length ? {
headingLines: [],
bodyLines: restLines,
lineHeight: unit.lineHeight
} : null,
height: chunk.height
};
}
function fillXPageUnits(units, unitIndex, availableHeight) {
var pageUnits = [];
var remainingHeight = availableHeight;
while (unitIndex < units.length) {
var budget = remainingHeight;
if (pageUnits.length) {
budget -= X_CAPTURE_UNIT_GAP;
}
if (budget <= 0) {
break;
}
var chunkInfo = buildXUnitChunk(units[unitIndex], budget);
if (!chunkInfo) {
break;
}
if (pageUnits.length) {
remainingHeight -= X_CAPTURE_UNIT_GAP;
}
pageUnits.push(chunkInfo.chunk);
remainingHeight -= chunkInfo.height;
if (chunkInfo.consumed) {
unitIndex += 1;
} else if (chunkInfo.rest) {
units[unitIndex] = chunkInfo.rest;
}
}
return {
units: pageUnits,
unitIndex: unitIndex,
remainingHeight: remainingHeight
};
}
function paginateXDetailUnits(units, unitIndex, pageCount, availableHeight) {
var workingUnits = cloneXDetailUnits(units);
var nextUnitIndex = unitIndex;
var pageUnits = [];
for (var pageIndex = 0; pageIndex < pageCount && nextUnitIndex < workingUnits.length; pageIndex += 1) {
var fill = fillXPageUnits(workingUnits, nextUnitIndex, availableHeight);
if (!fill.units.length) break;
pageUnits.push(fill.units);
nextUnitIndex = fill.unitIndex;
}
return {
pages: pageUnits,
consumed: nextUnitIndex >= workingUnits.length
};
}
function findBalancedXDetailHeight(units, unitIndex, pageCount, maxHeight) {
var low = 180;
var high = maxHeight;
while (low < high) {
var middle = Math.floor((low + high) / 2);
var attempt = paginateXDetailUnits(units, unitIndex, pageCount, middle);
if (attempt.consumed) {
high = middle;
} else {
low = middle + 1;
}
}
return Math.min(maxHeight, high + 18);
}
function buildXPagesForItem(ctx, item, itemIndex, contentWidth) {
var bodyWidth = contentWidth - 32;
var maxHeight = X_CAPTURE_CONTENT_BOTTOM - X_CAPTURE_CONTENT_TOP;
var heroHeader = measureXHeroHeader(ctx, item, contentWidth);
var units = createXDetailUnits(ctx, item, bodyWidth);
var unitIndex = 0;
var pages = [];
pages.push({
kind: 'hero',
item: item,
itemIndex: itemIndex,
heroHeader: heroHeader,
units: [],
hasMore: units.length > 0
});
var detailHeader = measureXDetailHeader(ctx, item, contentWidth, true);
var detailAvailableHeight = maxHeight - detailHeader.height;
var singleDetail = paginateXDetailUnits(units, unitIndex, 1, detailAvailableHeight);
if (singleDetail.consumed && singleDetail.pages.length === 1) {
pages.push({
kind: 'detail',
item: item,
itemIndex: itemIndex,
header: detailHeader,
units: singleDetail.pages[0],
hasMore: false
});
return pages;
}
var detailColumnGap = 24;
var detailColumnWidth = Math.floor((contentWidth - detailColumnGap) / 2);
var columnUnits = createXDetailUnits(ctx, item, detailColumnWidth - 36);
var twoColumnDetail = paginateXDetailUnits(columnUnits, 0, 2, detailAvailableHeight);
if (twoColumnDetail.consumed && twoColumnDetail.pages.length === 2) {
pages.push({
kind: 'detail-columns',
item: item,
itemIndex: itemIndex,
header: detailHeader,
columns: twoColumnDetail.pages,
columnGap: detailColumnGap,
hasMore: false
});
return pages;
}
var greedyDetails = paginateXDetailUnits(units, unitIndex, 3, detailAvailableHeight);
if (greedyDetails.consumed && greedyDetails.pages.length) {
var balancedHeight = findBalancedXDetailHeight(units, unitIndex, greedyDetails.pages.length, detailAvailableHeight);
var balancedDetails = paginateXDetailUnits(units, unitIndex, greedyDetails.pages.length, balancedHeight);
var selectedDetails = balancedDetails.consumed ? balancedDetails.pages : greedyDetails.pages;
selectedDetails.forEach(function(detailUnits, detailIndex) {
pages.push({
kind: 'detail',
item: item,
itemIndex: itemIndex,
header: measureXDetailHeader(ctx, item, contentWidth, true),
units: detailUnits,
hasMore: detailIndex < selectedDetails.length - 1
});
});
return pages;
}
var continued = unitIndex < units.length;
while (unitIndex < units.length) {
var header = measureXDetailHeader(ctx, item, contentWidth, continued);
var availableHeight = maxHeight - header.height;
var isLastAllowedPage = pages.length === 3;
var detailFill;
if (isLastAllowedPage) {
var unitsBeforeFinalFill = cloneXDetailUnits(units);
var fullFinalFill = fillXPageUnits(units, unitIndex, availableHeight);
if (fullFinalFill.unitIndex >= units.length) {
detailFill = fullFinalFill;
} else {
units = unitsBeforeFinalFill;
var overflowNotice = createXOverflowNoticeUnit(ctx, bodyWidth);
var noticeHeight = getXUnitHeight(overflowNotice);
detailFill = fillXPageUnits(units, unitIndex, Math.max(0, availableHeight - noticeHeight - X_CAPTURE_UNIT_GAP));
overflowNotice.height = noticeHeight;
detailFill.units.push(overflowNotice);
detailFill.unitIndex = units.length;
}
} else {
detailFill = fillXPageUnits(units, unitIndex, availableHeight);
}
if (!detailFill.units.length) {
return null;
}
unitIndex = detailFill.unitIndex;
pages.push({
kind: 'detail',
item: item,
itemIndex: itemIndex,
header: header,
units: detailFill.units,
hasMore: unitIndex < units.length
});
continued = unitIndex < units.length;
}
return pages;
}
function getXItemTopicLabel(page) {
return '運行情報';
}
function drawXPageChrome(ctx, pageIndex, totalPages, subHeading) {
var titleX = X_CAPTURE_SAFE_X;
ctx.fillStyle = '#0099CB';
ctx.fillRect(titleX, X_CAPTURE_HEADER_TOP + 4, 12, 76);
ctx.fillStyle = '#0f1720';
ctx.font = "800 56px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
ctx.fillText('JR四国 列車運行情報', titleX + 28, X_CAPTURE_HEADER_TOP + 57);
ctx.fillStyle = '#4b5563';
ctx.font = "700 24px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
ctx.fillText(subHeading, titleX + 30, X_CAPTURE_HEADER_TOP + 96);
ctx.fillStyle = '#0f1720';
ctx.font = "700 24px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
var pageText = getXPageNumberText(pageIndex, totalPages);
var pageTextWidth = ctx.measureText(pageText).width;
ctx.fillText(pageText, X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X - pageTextWidth, X_CAPTURE_HEADER_TOP + 52);
}
function drawXFooter(ctx, pageIndex, totalPages) {
var notice = getXCaptureNotice();
ctx.strokeStyle = '#d6dde5';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(X_CAPTURE_SAFE_X, X_CAPTURE_FOOTER_Y - 18);
ctx.lineTo(X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X, X_CAPTURE_FOOTER_Y - 18);
ctx.stroke();
ctx.fillStyle = '#5b6470';
ctx.font = "400 20px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
wrapText(ctx, notice, X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X * 2 - 90).slice(0, 3).forEach(function(line, index) {
ctx.fillText(line, X_CAPTURE_SAFE_X, X_CAPTURE_FOOTER_Y + index * 26);
});
ctx.fillStyle = '#111827';
ctx.font = "800 22px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
var pageText = getXPageNumberText(pageIndex, totalPages);
var pageWidth = ctx.measureText(pageText).width;
ctx.fillText(pageText, X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X - pageWidth, X_CAPTURE_FOOTER_Y + 52);
}
function drawXDetailHeaderBlock(ctx, header, x, y, width) {
ctx.fillStyle = '#0099CB';
ctx.fillRect(x, y, width, header.height);
ctx.fillStyle = '#ffffff';
ctx.font = "800 38px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
var textY = y + 44;
header.titleLines.forEach(function(line) {
ctx.fillText(line, x + 22, textY);
textY += 44;
});
if (header.subTitleLines.length) {
ctx.font = "700 26px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
textY += 2;
header.subTitleLines.forEach(function(line) {
ctx.fillText(line, x + 22, textY);
textY += 32;
});
}
if (header.updatedLines.length) {
ctx.font = "500 22px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
textY += 4;
header.updatedLines.forEach(function(line) {
ctx.fillText(line, x + 22, textY);
textY += 28;
});
}
if (header.continued) {
ctx.fillStyle = 'rgba(255,255,255,.22)';
ctx.fillRect(x + width - 152, y + 18, 124, 34);
ctx.fillStyle = '#ffffff';
ctx.font = "700 20px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
ctx.fillText('続き', x + width - 109, y + 41);
}
}
function drawXUnitBlock(ctx, unit, x, y, width) {
ctx.strokeStyle = '#d7dee7';
ctx.lineWidth = 2;
ctx.strokeRect(x, y, width, unit.height);
var textY = y + 34;
if (unit.headingLines.length) {
var headingHeight = Math.max(56, unit.headingLines.length * 35 + 20);
ctx.fillStyle = '#f3f8fb';
ctx.fillRect(x, y, width, headingHeight + 20);
ctx.fillStyle = '#0099CB';
ctx.fillRect(x + 14, y + 12, 8, headingHeight - 4);
ctx.fillStyle = '#0f1720';
ctx.font = "700 28px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
unit.headingLines.forEach(function(line) {
ctx.fillText(line, x + 34, textY);
textY += 35;
});
textY = y + headingHeight + 34;
}
ctx.fillStyle = '#111827';
ctx.font = "400 28px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
unit.bodyLines.forEach(function(line) {
ctx.fillText(line, x + 18, textY);
textY += unit.lineHeight;
});
}
async function buildXHeroPage(page, pageIndex, totalPages) {
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');
if (!ctx) return null;
canvas.width = X_CAPTURE_PAGE_WIDTH;
canvas.height = X_CAPTURE_PAGE_HEIGHT;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawXPageChrome(ctx, pageIndex, totalPages, '運行情報・路線図');
var contentWidth = X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X * 2;
var heroTop = 180;
var hero = page.heroHeader;
var heroWidth = contentWidth;
ctx.fillStyle = '#0e7fb1';
ctx.fillRect(X_CAPTURE_SAFE_X, heroTop, heroWidth, hero.height);
ctx.fillStyle = '#cfeefe';
ctx.font = "800 22px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
ctx.fillText(getXItemTopicLabel(page), X_CAPTURE_SAFE_X + 28, heroTop + 34);
ctx.fillStyle = '#ffffff';
ctx.font = "800 64px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
var textY = heroTop + 92;
hero.titleLines.forEach(function(line) {
ctx.fillText(line, X_CAPTURE_SAFE_X + 26, textY);
textY += 70;
});
if (hero.subTitleLines.length) {
ctx.fillStyle = '#def5ff';
ctx.font = "700 30px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
hero.subTitleLines.forEach(function(line) {
ctx.fillText(line, X_CAPTURE_SAFE_X + 28, textY);
textY += 36;
});
}
if (hero.updatedLines.length) {
ctx.fillStyle = '#d4ecfa';
ctx.font = "500 24px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
hero.updatedLines.forEach(function(line) {
ctx.fillText(line, X_CAPTURE_SAFE_X + 28, textY + 10);
textY += 30;
});
}
if (hero.leadLines.length) {
ctx.fillStyle = '#ffffff';
ctx.font = "500 26px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
var leadY = heroTop + hero.height - hero.leadLines.length * 34 - 20;
hero.leadLines.forEach(function(line, index) {
ctx.fillText(line, X_CAPTURE_SAFE_X + 28, leadY + index * 34);
});
}
var mapCanvas;
try {
mapCanvas = await buildMapImage(contentWidth - 32);
} catch (e) {
return null;
}
if (!mapCanvas) return null;
var mapTop = heroTop + hero.height + 24;
ctx.strokeStyle = '#c7dcea';
ctx.lineWidth = 2;
ctx.strokeRect(X_CAPTURE_SAFE_X, mapTop, contentWidth, mapCanvas.height + 32);
ctx.drawImage(mapCanvas, X_CAPTURE_SAFE_X + 16, mapTop + 16, mapCanvas.width, mapCanvas.height);
var detailY = mapTop + mapCanvas.height + 48;
if (page.units.length) {
page.units.forEach(function(unit, unitIndex) {
drawXUnitBlock(ctx, unit, X_CAPTURE_SAFE_X, detailY, contentWidth);
detailY += unit.height;
if (unitIndex < page.units.length - 1) {
detailY += X_CAPTURE_UNIT_GAP;
}
});
} else {
ctx.fillStyle = '#f3f8fb';
ctx.fillRect(X_CAPTURE_SAFE_X, detailY, contentWidth, 112);
ctx.fillStyle = '#0f1720';
ctx.font = "700 28px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
ctx.fillText(page.hasMore ? '詳細情報は2枚目以降へ →' : 'この題目の詳細はJR四国公式をご確認ください。', X_CAPTURE_SAFE_X + 24, detailY + 62);
}
drawXFooter(ctx, pageIndex, totalPages);
return canvas;
}
function buildXDetailPage(page, pageIndex, totalPages) {
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');
if (!ctx) return null;
canvas.width = X_CAPTURE_PAGE_WIDTH;
canvas.height = X_CAPTURE_PAGE_HEIGHT;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawXPageChrome(ctx, pageIndex, totalPages, '詳細情報');
var y = X_CAPTURE_CONTENT_TOP;
var width = X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X * 2;
drawXDetailHeaderBlock(ctx, page.header, X_CAPTURE_SAFE_X, y, width);
y += page.header.height;
page.units.forEach(function(unit, unitIndex) {
drawXUnitBlock(ctx, unit, X_CAPTURE_SAFE_X, y, width);
y += unit.height;
if (unitIndex < page.units.length - 1) {
y += X_CAPTURE_UNIT_GAP;
}
});
drawXFooter(ctx, pageIndex, totalPages);
return canvas;
}
function buildXDetailColumnsPage(page, pageIndex, totalPages) {
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');
if (!ctx) return null;
canvas.width = X_CAPTURE_PAGE_WIDTH;
canvas.height = X_CAPTURE_PAGE_HEIGHT;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawXPageChrome(ctx, pageIndex, totalPages, '詳細情報');
var y = X_CAPTURE_CONTENT_TOP;
var width = X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X * 2;
drawXDetailHeaderBlock(ctx, page.header, X_CAPTURE_SAFE_X, y, width);
y += page.header.height + X_CAPTURE_UNIT_GAP;
var gap = page.columnGap || 24;
var columnWidth = Math.floor((width - gap) / 2);
(page.columns || []).forEach(function(columnUnits, columnIndex) {
var columnX = X_CAPTURE_SAFE_X + columnIndex * (columnWidth + gap);
var columnY = y;
columnUnits.forEach(function(unit, unitIndex) {
drawXUnitBlock(ctx, unit, columnX, columnY, columnWidth);
columnY += unit.height;
if (unitIndex < columnUnits.length - 1) {
columnY += X_CAPTURE_UNIT_GAP;
}
});
});
drawXFooter(ctx, pageIndex, totalPages);
return canvas;
}
function getXFileToken(item, fallback) {
var base = strip(item && (item.infoId || item.title)) || fallback || 'item';
return base.replace(/[^0-9A-Za-z_-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 24) || 'item';
}
function formatXPageFileIndex(index) {
var value = String(index + 1);
return value.length > 1 ? value : '0' + value;
}
async function renderXPostImageSet(items, fileNameBase) {
try {
if (!items || !items.length) {
postMessage({ error: true, reason: 'x-empty' });
return;
}
var measureCanvas = document.createElement('canvas');
var measureCtx = measureCanvas.getContext('2d');
if (!measureCtx) {
postMessage({ error: true });
return;
}
var pages = [];
var contentWidth = X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X * 2;
for (var itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
var itemPages = buildXPagesForItem(measureCtx, items[itemIndex], itemIndex, contentWidth);
if (!itemPages) {
postMessage({ error: true, reason: 'x-overflow' });
return;
}
pages = pages.concat(itemPages);
}
if (!pages.length || pages.length > 4) {
postMessage({ error: true, reason: 'x-overflow' });
return;
}
var totalPages = pages.length;
var timestamp = strip(fileNameBase) || String(Date.now());
var renderedPages = [];
for (var pageIndex = 0; pageIndex < pages.length; pageIndex += 1) {
var page = pages[pageIndex];
var canvas = page.kind === 'hero'
? await buildXHeroPage(page, pageIndex, totalPages)
: page.kind === 'detail-columns'
? buildXDetailColumnsPage(page, pageIndex, totalPages)
: buildXDetailPage(page, pageIndex, totalPages);
if (!canvas) {
postMessage({ error: true, reason: page.kind === 'hero' ? 'x-map' : undefined });
return;
}
renderedPages.push({
canvas: canvas,
token: getXFileToken(page.item, String(page.itemIndex + 1)),
kind: page.kind
});
}
var batchId = 'operation-info-x-batch-' + Date.now() + '-' + Math.floor(Math.random() * 100000);
for (var index = 0; index < renderedPages.length; index += 1) {
var rendered = renderedPages[index];
postMessage({
batchId: batchId,
batchIndex: index,
batchTotal: renderedPages.length,
dataUrl: rendered.canvas.toDataURL('image/png'),
fileName: 'operation-info-x-' + formatXPageFileIndex(index) + '-' + rendered.token + '-' + rendered.kind + '-' + timestamp + '.png'
});
}
} catch (e) {
postMessage({ error: true });
}
}
function appendCaptureSectionLayout(layout, ctx, section, startY, options) {
var boxX = options && options.x != null ? options.x : 0;
var boxY = startY;
@@ -1870,22 +1105,20 @@ const buildOperationPageScript = (layout: {
link.onclick = function(event) {
event.preventDefault();
event.stopPropagation();
runCaptureAction(function() {
var updatedAtNode = q('.upd_time', dd);
var subTitleNode = q('.delay_subttl', heading);
var sectionHtml = getSubsectionHtml(midasi);
if (!sectionHtml) {
postMessage({ error: true });
return;
}
return renderItemToImage(
getTitleText(heading),
subTitleNode ? subTitleNode.textContent : '',
updatedAtNode ? updatedAtNode.textContent : '',
sectionHtml,
'operation-info-' + infoId + '-section-' + index + '-' + Date.now()
);
});
var updatedAtNode = q('.upd_time', dd);
var subTitleNode = q('.delay_subttl', heading);
var sectionHtml = getSubsectionHtml(midasi);
if (!sectionHtml) {
postMessage({ error: true });
return;
}
renderItemToImage(
getTitleText(heading),
subTitleNode ? subTitleNode.textContent : '',
updatedAtNode ? updatedAtNode.textContent : '',
sectionHtml,
'operation-info-' + infoId + '-section-' + index + '-' + Date.now()
);
};
wrap.appendChild(link);
if (midasi.nextSibling) {
@@ -1924,9 +1157,7 @@ const buildOperationPageScript = (layout: {
allLink.onclick = function(event) {
event.preventDefault();
event.stopPropagation();
runCaptureAction(function() {
return renderAllItemsToImage(getOperationItems(), getStatusText(q('.delay_status')), 'operation-info-all-' + Date.now());
});
renderAllItemsToImage(getOperationItems(), getStatusText(q('.delay_status')), 'operation-info-all-' + Date.now());
};
wrap.appendChild(allLink);
}
@@ -1941,31 +1172,13 @@ const buildOperationPageScript = (layout: {
batchLink.onclick = function(event) {
event.preventDefault();
event.stopPropagation();
runCaptureAction(function() {
return renderAllItemsToSeparateImages(getOperationItems(), 'operation-info-share-' + Date.now());
});
renderAllItemsToSeparateImages(getOperationItems(), 'operation-info-share-' + Date.now());
};
wrap.appendChild(batchLink);
}
} else if (batchLink && batchLink.parentNode) {
batchLink.parentNode.removeChild(batchLink);
}
var xLink = q('.jrs-capture-page-link.is-x', wrap);
if (!xLink) {
xLink = document.createElement('a');
xLink.href = '#';
xLink.className = 'jrs-capture-page-link is-x';
xLink.textContent = 'X投稿向け画像を作成';
xLink.onclick = function(event) {
event.preventDefault();
event.stopPropagation();
runCaptureAction(function() {
return renderXPostImageSet(getOperationItems(), String(Date.now()));
});
};
wrap.appendChild(xLink);
}
}
function removeCaptureButtons() {
@@ -2003,44 +1216,21 @@ const buildOperationPageScript = (layout: {
link.onclick = function(event) {
event.preventDefault();
event.stopPropagation();
runCaptureAction(function() {
var updatedAtNode = q('.upd_time', dd);
var subTitleNode = q('.delay_subttl', heading);
if (!detailNode.innerHTML) {
postMessage({ error: true });
return;
}
return renderItemToImage(
getTitleText(heading),
subTitleNode ? subTitleNode.textContent : '',
updatedAtNode ? updatedAtNode.textContent : '',
detailNode.innerHTML,
'operation-info-' + infoId + '-' + Date.now()
);
});
var updatedAtNode = q('.upd_time', dd);
var subTitleNode = q('.delay_subttl', heading);
if (!detailNode.innerHTML) {
postMessage({ error: true });
return;
}
renderItemToImage(
getTitleText(heading),
subTitleNode ? subTitleNode.textContent : '',
updatedAtNode ? updatedAtNode.textContent : '',
detailNode.innerHTML,
'operation-info-' + infoId + '-' + Date.now()
);
};
wrap.appendChild(link);
var xItemLink = document.createElement('a');
xItemLink.href = '#';
xItemLink.className = 'jrs-capture-link is-x';
xItemLink.textContent = 'この項目でX投稿向け画像を作成';
xItemLink.onclick = function(event) {
event.preventDefault();
event.stopPropagation();
runCaptureAction(function() {
var updatedAtNode = q('.upd_time', dd);
var subTitleNode = q('.delay_subttl', heading);
return renderXPostImageSet([{
infoId: infoId,
title: getTitleText(heading) || '運行情報',
subTitle: strip(subTitleNode ? subTitleNode.textContent : ''),
updatedAt: strip(updatedAtNode ? updatedAtNode.textContent : ''),
blocks: parseDetailBlocks(detailNode.innerHTML || '')
}], infoId + '-' + Date.now());
});
};
wrap.appendChild(xItemLink);
dd.insertBefore(wrap, dd.firstChild);
}
@@ -3443,12 +2633,7 @@ export default function tndView() {
},
});
if (payload.error) {
const message = payload.reason === "x-overflow"
? "X投稿向け画像は4枚以内に読みやすく収まりませんでした。『項目ごとに複数画像で共有』をご利用ください。"
: payload.reason === "x-map"
? "路線図の読み込みに失敗したため、X投稿向け画像を生成できませんでした。"
: "項目画像の生成に失敗しました。";
Alert.alert("切り出し失敗", message);
Alert.alert("切り出し失敗", "項目画像の生成に失敗しました。");
return;
}
if (payload.batchId) {
+1 -3
View File
@@ -6,7 +6,6 @@
"android": "expo run:android",
"ios": "expo run:ios",
"eject": "expo eject",
"postinstall": "patch-package",
"pushWeb": "npx expo export -p web && netlify deploy --dir dist --prod",
"checkDiagram": "bash ./check.sh"
},
@@ -107,8 +106,7 @@
"devDependencies": {
"@types/react": "~19.2.14",
"babel-preset-expo": "~55.0.12",
"baseline-browser-mapping": "^2.10.9",
"patch-package": "^8.0.1"
"baseline-browser-mapping": "^2.10.9"
},
"private": true,
"name": "jrshikoku",
@@ -1,18 +0,0 @@
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}
+50 -93
View File
@@ -6,14 +6,9 @@ import React, {
useRef,
FC,
} from "react";
import { AppState, InteractionManager } from "react-native";
import { observedFetchJson } from "@/lib/observability/network/observedFetch";
import { API_ENDPOINTS } from "@/constants";
import {
getNextOperationInfoFetchDelay,
OPERATION_INFO_STALE_RETRY_MS,
} from "@/lib/operationInfoSchedule";
import type { OperationInfoSnapshot } from "@/types";
import { InteractionManager } from "react-native";
import useInterval from "../lib/useInterval";
import { observedFetchJson, observedFetchText } from "@/lib/observability/network/observedFetch";
const setoStationID = [
"Y00",
@@ -367,136 +362,98 @@ type props = { children: React.ReactNode };
export const AreaInfoProvider: FC<props> = ({ children }) => {
const [areaInfo, setAreaInfo] = useState("");
const [areaIconBadgeText, setAreaIconBadgeText] = useState("");
const [areaStationID, setAreaStationID] = useState<string[]>([]);
const [areaStationID, setAreaStationID] = useState([]);
const [isInfo, setIsInfo] = useState(false);
const areaDescriptionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initialFetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const nextFetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const getAreaDataRef = useRef<() => void>(() => {});
const isFetchingRef = useRef(false);
const isMountedRef = useRef(false);
const isActiveRef = useRef(true);
const clearNextFetchTimeout = () => {
if (nextFetchTimeoutRef.current) {
clearTimeout(nextFetchTimeoutRef.current);
nextFetchTimeoutRef.current = null;
}
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) => setAreaInfo(d))
.catch(() => {});
};
const scheduleNextFetch = (delayMs: number) => {
if (!isMountedRef.current || !isActiveRef.current) return;
clearNextFetchTimeout();
nextFetchTimeoutRef.current = setTimeout(() => {
nextFetchTimeoutRef.current = null;
getAreaDataRef.current();
}, delayMs);
const scheduleAreaDescriptionFetch = () => {
if (areaDescriptionTimeoutRef.current) {
clearTimeout(areaDescriptionTimeoutRef.current);
}
areaDescriptionTimeoutRef.current = setTimeout(() => {
areaDescriptionTimeoutRef.current = null;
fetchAreaDescription();
}, 800);
};
const getAreaData = () => {
if (isFetchingRef.current || !isActiveRef.current) return;
isFetchingRef.current = true;
observedFetchJson<OperationInfoSnapshot>(API_ENDPOINTS.OPERATION_INFO, {
endpoint: "operation_info",
source: "static_storage",
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,
cache: "no-store",
urlPathTemplate: "/operation-info/jr-shikoku/latest.json",
urlPathTemplate: "/webhook/jr-shikoku-trainfo-flag",
})
.then((d) => {
scheduleNextFetch(getNextOperationInfoFetchDelay(d.fetchedAt));
const areaData = d.compatibility?.areaInfo;
if (!Array.isArray(areaData)) return;
const lineInfo = areaData.filter((e) => e.area !== "genelic");
const generalInfo = areaData.find((e) => e.area === "genelic");
const activeLineInfo = lineInfo.filter(
(e) => Boolean(e.status) && e.area in areaStationPair
);
if (!d.data) return;
const lineInfo = d.data.filter((e) => e.area != "genelic");
const genelicInfo = d.data.filter((e) => e.area == "genelic");
const activeLineInfo = lineInfo.filter((e) => e.status);
const text = activeLineInfo.map((e) => {
return areaStationPair[e.area as keyof typeof areaStationPair].id;
return `${areaStationPair[e.area].id}`;
});
let stationIDList: string[] = [];
let stationIDList = [];
activeLineInfo.forEach((e) => {
stationIDList = stationIDList.concat(
areaStationPair[e.area as keyof typeof areaStationPair].stationID
areaStationPair[e.area].stationID
);
});
const info =
typeof generalInfo?.status === "string" &&
generalInfo.status.includes("nodelay");
const info = genelicInfo[0].status.includes("nodelay") ? true : false;
setIsInfo(info);
setAreaStationID(stationIDList);
setAreaIconBadgeText(
text.length == 0 ? (info ? "i" : "!") : text.join(",")
);
if (stationIDList.length > 0) {
setAreaInfo(d.compatibility.operationInfoText);
scheduleAreaDescriptionFetch();
} else {
setAreaInfo("");
}
})
.catch(() => {
scheduleNextFetch(OPERATION_INFO_STALE_RETRY_MS);
})
.finally(() => {
isFetchingRef.current = false;
});
.catch(() => {});
};
getAreaDataRef.current = getAreaData;
useEffect(() => {
isMountedRef.current = true;
isActiveRef.current =
AppState.currentState !== "background" &&
AppState.currentState !== "inactive";
const task = InteractionManager.runAfterInteractions(() => {
if (!isActiveRef.current) return;
initialFetchTimeoutRef.current = setTimeout(() => {
initialFetchTimeoutRef.current = null;
getAreaDataRef.current();
getAreaData();
}, 1200);
});
const subscription = AppState.addEventListener("change", (nextState) => {
task.cancel?.();
if (initialFetchTimeoutRef.current) {
clearTimeout(initialFetchTimeoutRef.current);
initialFetchTimeoutRef.current = null;
}
if (nextState === "active") {
isActiveRef.current = true;
clearNextFetchTimeout();
getAreaDataRef.current();
return;
}
isActiveRef.current = false;
clearNextFetchTimeout();
if (initialFetchTimeoutRef.current) {
clearTimeout(initialFetchTimeoutRef.current);
initialFetchTimeoutRef.current = null;
}
});
return () => {
isMountedRef.current = false;
isActiveRef.current = false;
task.cancel?.();
subscription.remove();
if (initialFetchTimeoutRef.current) {
clearTimeout(initialFetchTimeoutRef.current);
initialFetchTimeoutRef.current = null;
}
clearNextFetchTimeout();
if (areaDescriptionTimeoutRef.current) {
clearTimeout(areaDescriptionTimeoutRef.current);
areaDescriptionTimeoutRef.current = null;
}
};
}, []);
useInterval(getAreaData, 60000); //60秒毎に全在線列車取得
return (
<AreaInfoContext.Provider
value={{
+1 -1
View File
@@ -130,7 +130,7 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
let nearest: StationProps | null = null;
let nearestDist = Infinity;
Object.keys(originalStationList).forEach((lineName) => {
originalStationList[lineName]?.forEach((station) => {
(originalStationList as any)[lineName]?.forEach((station: StationProps) => {
const d = _calcDistance({ lat: station.lat, lng: station.lng }, userPos);
if (d < nearestDist) {
nearestDist = d;
+6 -10
View File
@@ -10,13 +10,11 @@ import {
getStationList,
lineList_LineWebID,
} from "@/lib/getStationList";
import { OriginalStationList, StationProps } from "@/lib/CommonTypes";
import { StationProps } from "@/lib/CommonTypes";
type initialStateType = {
originalStationList: OriginalStationList;
setOriginalStationList: React.Dispatch<
React.SetStateAction<OriginalStationList>
>;
originalStationList: StationProps[][];
setOriginalStationList: React.Dispatch<React.SetStateAction<StationProps[][]>>;
getStationDataFromName: (id: string) => StationProps[];
getStationDataFromId: (id: string) => StationProps[];
getStationDataFromNameBase: (name: string) => StationProps[];
@@ -29,7 +27,7 @@ type initialStateType = {
};
const initialState = {
originalStationList: {},
originalStationList: [[]],
setOriginalStationList: () => {},
getStationDataFromName: () => [],
getStationDataFromId: () => [],
@@ -53,8 +51,7 @@ export const useStationList = () => {
};
export const StationListProvider: FC<Props> = ({ children }) => {
const [originalStationList, setOriginalStationList] =
useState<OriginalStationList>({});
const [originalStationList, setOriginalStationList] = useState<StationProps[][]>([]);
useEffect(() => {
getStationList().then(setOriginalStationList);
}, []);
@@ -108,10 +105,9 @@ export const StationListProvider: FC<Props> = ({ children }) => {
const [stationList, setStationList] = useState<StationProps[][]>([[]]);
useEffect(() => {
if (Object.keys(originalStationList).length === 0) return;
if (originalStationList.length === 0) return;
const stationList = lineList.map((d) =>
originalStationList[d].map((a) => ({
...a,
StationNumber: a.StationNumber,
StationName: a.Station_JP,
}))
+6 -9
View File
@@ -6,18 +6,16 @@ import React, {
FC,
} from "react";
import { lineList, getStationList } from "../lib/getStationList";
import { OriginalStationList, StationProps } from "@/lib/CommonTypes";
import { StationProps } from "@/lib/CommonTypes";
type initialStateType = {
originalStationList: OriginalStationList;
setOriginalStationList: React.Dispatch<
React.SetStateAction<OriginalStationList>
>;
originalStationList: StationProps[][];
setOriginalStationList: React.Dispatch<React.SetStateAction<StationProps[][]>>;
getStationData: (id: string) => StationProps[];
stationList: { StationNumber: string; StationName: string }[][];
};
const initialState = {
originalStationList: {},
originalStationList: [[]],
setOriginalStationList: () => {},
getStationData: () => [],
stationList: [],
@@ -32,8 +30,7 @@ export const useTopMenu = () => {
};
export const TopMenuProvider: FC<Props> = ({ children }) => {
const [originalStationList, setOriginalStationList] =
useState<OriginalStationList>({});
const [originalStationList, setOriginalStationList] = useState<StationProps[][]>([]);
useEffect(() => {
getStationList().then(setOriginalStationList);
}, []);
@@ -50,7 +47,7 @@ export const TopMenuProvider: FC<Props> = ({ children }) => {
};
const [stationList, setStationList] = useState<{ StationNumber: string; StationName: string }[][]>([[]]);
useEffect(()=>{
if (Object.keys(originalStationList).length === 0) return;
if(originalStationList.length === 0) return;
const stationList =
originalStationList &&
lineList.map((d) =>
+5 -53
View File
@@ -80,10 +80,6 @@ const initialState = {
setTrainMenu: (e) => {},
updatePermission: false,
setUpdatePermission: (e) => {},
/** バックエンドが返したユーザーロール */
userPermissionRole: "",
/** crew/administrator向け音声機能の表示・利用権限 */
restrictedSoundPermission: false,
/** 各情報ソースの利用権限 */
dataSourcePermission: { unyohub: false, elesite: false } as {
unyohub: boolean;
@@ -111,8 +107,6 @@ const initialState = {
activeRecording: null as TrainRecording | null,
/** 現在再生中のスナップショットインデックス */
playbackIndex: 0,
/** 再生中スナップショットに対応する基準時刻(通常時は null) */
playbackCurrentTimeIso: null as string | null,
/** 再生一時停止中か */
playbackPaused: false,
startRecording: () => {},
@@ -164,57 +158,25 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
//更新権限所有確認・情報ソース別利用権限(将来ロールが増えたらここに足す)
const [updatePermission, setUpdatePermission] = useState(false);
const [userPermissionRole, setUserPermissionRole] = useState("");
const [restrictedSoundPermission, setRestrictedSoundPermission] =
useState(false);
const [dataSourcePermission, setDataSourcePermission] = useState<{
unyohub: boolean;
elesite: boolean;
}>({ unyohub: false, elesite: false });
useEffect(() => {
if (!expoPushToken) {
setUserPermissionRole("");
setUpdatePermission(false);
setRestrictedSoundPermission(false);
setDataSourcePermission({ unyohub: false, elesite: false });
return;
}
setUserPermissionRole("");
setUpdatePermission(false);
setRestrictedSoundPermission(false);
setDataSourcePermission({ unyohub: false, elesite: false });
const permissionController = new AbortController();
if (!expoPushToken) return;
fetch(
`${backendApiBaseUrl}/check-permission?user_id=${expoPushToken}`,
{ signal: permissionController.signal },
)
.then((res) => res.json())
.then((res) => {
if (permissionController.signal.aborted) return;
const role: string = res.permission ?? "";
const normalizedRole = role.trim().toLowerCase();
const isAdministrator = normalizedRole === "administrator";
setUserPermissionRole(normalizedRole);
setUpdatePermission(isAdministrator);
setRestrictedSoundPermission(
normalizedRole === "crew" || isAdministrator,
);
setUpdatePermission(role === "administrator");
setDataSourcePermission({
unyohub: isAdministrator || role === "unyoHubEditor",
elesite: isAdministrator || role === "eleSiteEditor",
unyohub: role === "administrator" || role === "unyoHubEditor",
elesite: role === "administrator" || role === "eleSiteEditor",
});
})
.catch(() => {
if (permissionController.signal.aborted) return;
setUserPermissionRole("");
setUpdatePermission(false);
setRestrictedSoundPermission(false);
setDataSourcePermission({ unyohub: false, elesite: false });
});
return () => permissionController.abort();
.catch(() => {});
}, [expoPushToken, backendApiBaseUrl]);
//列車情報表示関連
@@ -267,13 +229,6 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
const [recordingList, setRecordingList] = useState<RecordingMeta[]>([]);
const [activeRecording, setActiveRecording] = useState<TrainRecording | null>(null);
const [playbackIndex, setPlaybackIndex] = useState(0);
const playbackCurrentTimeIso =
recorderState === 'playing' && activeRecording && activeRecording.snapshots.length > 0
? new Date(
new Date(activeRecording.recordedAt).getTime() +
(activeRecording.snapshots[playbackIndex]?.t ?? 0)
).toISOString()
: null;
const [playbackPaused, setPlaybackPaused] = useState(false);
const recordingStartTimeRef = useRef<number>(0);
const recordingSnapshotsRef = useRef<Array<{ t: number; trains: TrainEntry[] }>>([]);
@@ -594,8 +549,6 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
setTrainMenu,
updatePermission,
setUpdatePermission,
userPermissionRole,
restrictedSoundPermission,
dataSourcePermission,
injectJavascript,
injectJavascriptBeforeContentLoaded,
@@ -610,7 +563,6 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
recordingList,
activeRecording,
playbackIndex,
playbackCurrentTimeIso,
playbackPaused,
startRecording,
stopRecording,
+15 -14
View File
@@ -10,6 +10,8 @@ struct OperationEntry: TimelineEntry {
}
struct OperationInfoProvider: TimelineProvider {
private let endpoint = "https://script.google.com/macros/s/AKfycbz80LcaEUrhnlEsLkJy0LG2IRO3DBVQhfNmN1d_0f_HvtsujNQpxM90SrV9yKWH_JG1Ww/exec"
func placeholder(in context: Context) -> OperationEntry {
OperationEntry(date: Date(), text: "読み込み中…", isLoading: true)
}
@@ -30,21 +32,20 @@ struct OperationInfoProvider: TimelineProvider {
}
private func fetchData(completion: @escaping (OperationEntry) -> Void) {
fetchOperationInfoSnapshot { result in
let operationInfoText: String
switch result {
case .success(let snapshot):
operationInfoText = snapshot.compatibility.operationInfoText
case .failure:
operationInfoText = ""
}
let displayText = operationInfoText.isEmpty
? "通常運行中です。"
: operationInfoText.replacingOccurrences(of: "^", with: "\n")
completion(OperationEntry(date: Date(), text: displayText, isLoading: false))
guard let url = URL(string: endpoint) else {
completion(OperationEntry(date: Date(), text: "通常運行中です。", isLoading: false))
return
}
URLSession.shared.dataTask(with: url) { data, _, error in
guard let data = data, error == nil,
let text = String(data: data, encoding: .utf8),
!text.isEmpty else {
completion(OperationEntry(date: Date(), text: "通常運行中です。", isLoading: false))
return
}
let displayText = text.replacingOccurrences(of: "^", with: "\n")
completion(OperationEntry(date: Date(), text: displayText, isLoading: false))
}.resume()
}
}
-47
View File
@@ -1,9 +1,6 @@
import Foundation
import WidgetKit
import SwiftUI
let operationInfoSnapshotURL = "https://jr-shikoku-api-data-storage.haruk.in/operation-info/jr-shikoku/latest.json"
/// App Group ID shared between the main app and widget extension.
let appGroupID = "group.jrshikokuinfo.xprocess.hrkn"
@@ -16,50 +13,6 @@ struct FelicaSnapshot: Codable {
let scannedAt: String
}
struct OperationInfoCompatibility: Decodable {
let operationInfoText: String
let hasOperationInfo: Bool
}
struct OperationInfoSnapshot: Decodable {
let compatibility: OperationInfoCompatibility
}
enum OperationInfoFetchError: Error {
case invalidURL
case invalidResponse
}
func fetchOperationInfoSnapshot(completion: @escaping (Result<OperationInfoSnapshot, Error>) -> Void) {
guard let url = URL(string: operationInfoSnapshotURL) else {
completion(.failure(OperationInfoFetchError.invalidURL))
return
}
var request = URLRequest(
url: url,
cachePolicy: .reloadIgnoringLocalCacheData,
timeoutInterval: 15
)
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
URLSession.shared.dataTask(with: request) { data, response, error in
guard error == nil,
let response = response as? HTTPURLResponse,
(200..<300).contains(response.statusCode),
let data = data else {
completion(.failure(error ?? OperationInfoFetchError.invalidResponse))
return
}
do {
completion(.success(try JSONDecoder().decode(OperationInfoSnapshot.self, from: data)))
} catch {
completion(.failure(error))
}
}.resume()
}
func sharedDefaults() -> UserDefaults {
UserDefaults(suiteName: appGroupID) ?? .standard
}
+12 -5
View File
@@ -14,6 +14,7 @@ struct ShortcutEntry: TimelineEntry {
struct ShortcutProvider: TimelineProvider {
private let delayEndpoint = "https://script.google.com/macros/s/AKfycbw-0RDLAu8EQAEWA860tk4KVW6VOr3iIU900AcWEfqIP16gtNUG1XO_A3oBfAGiNeCf/exec"
private let operationEndpoint = "https://script.google.com/macros/s/AKfycbz80LcaEUrhnlEsLkJy0LG2IRO3DBVQhfNmN1d_0f_HvtsujNQpxM90SrV9yKWH_JG1Ww/exec"
func placeholder(in context: Context) -> ShortcutEntry {
ShortcutEntry(date: Date(), delayCount: 0, hasInfo: false, amountText: "未読取")
@@ -58,11 +59,17 @@ struct ShortcutProvider: TimelineProvider {
//
group.enter()
fetchOperationInfoSnapshot { result in
defer { group.leave() }
if case .success(let snapshot) = result {
hasInfo = snapshot.compatibility.hasOperationInfo
}
if let url = URL(string: operationEndpoint) {
URLSession.shared.dataTask(with: url) { data, _, _ in
defer { group.leave() }
if let data = data,
let text = String(data: data, encoding: .utf8),
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
hasInfo = true
}
}.resume()
} else {
group.leave()
}
// Felica
+1 -2
View File
@@ -6,8 +6,7 @@
}
],
"paths": {
"@/*": ["./*"],
"expo-live-activity": ["./modules/expo-live-activity/src/index.ts"]
"@/*": ["./*"]
}
},
"extends": "expo/tsconfig.base"
-2
View File
@@ -3,8 +3,6 @@
* 使
*/
export * from "./operationInfo";
/**
*
*/
-15
View File
@@ -1,15 +0,0 @@
export type OperationInfoAreaState = {
area: string;
status: boolean | string;
};
export type OperationInfoSnapshot = {
schemaVersion: 1;
status: "normal" | "disrupted";
fetchedAt: string;
compatibility: {
operationInfoText: string;
hasOperationInfo: boolean;
areaInfo: OperationInfoAreaState[];
};
};
+8 -225
View File
@@ -2084,11 +2084,6 @@
resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.7.13.tgz#ff34942667a4e19a9f4a0996a76814daac364cf3"
integrity sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==
"@yarnpkg/lockfile@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31"
integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==
abort-controller@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392"
@@ -2544,32 +2539,6 @@ cacheable-request@^7.0.2:
normalize-url "^6.0.1"
responselike "^2.0.0"
call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6"
integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==
dependencies:
es-errors "^1.3.0"
function-bind "^1.1.2"
call-bind@^1.0.8:
version "1.0.9"
resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.9.tgz#39a644700c80bc7d0ca9102fc6d1d43b2fd7eee7"
integrity sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==
dependencies:
call-bind-apply-helpers "^1.0.2"
es-define-property "^1.0.1"
get-intrinsic "^1.3.0"
set-function-length "^1.2.2"
call-bound@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a"
integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==
dependencies:
call-bind-apply-helpers "^1.0.2"
get-intrinsic "^1.3.0"
camelcase@^5.3.1:
version "5.3.1"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
@@ -2645,7 +2614,7 @@ ci-info@^2.0.0:
resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46"
integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==
ci-info@^3.2.0, ci-info@^3.3.0, ci-info@^3.7.0:
ci-info@^3.2.0, ci-info@^3.3.0:
version "3.9.0"
resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4"
integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==
@@ -2819,7 +2788,7 @@ cross-fetch@^3.1.5:
dependencies:
node-fetch "^2.7.0"
cross-spawn@^7.0.3, cross-spawn@^7.0.6:
cross-spawn@^7.0.6:
version "7.0.6"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
@@ -2926,15 +2895,6 @@ defer-to-connect@^2.0.0:
resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587"
integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==
define-data-property@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e"
integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==
dependencies:
es-define-property "^1.0.0"
es-errors "^1.3.0"
gopd "^1.0.1"
define-lazy-prop@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
@@ -2999,15 +2959,6 @@ domutils@^3.0.1:
domelementtype "^2.3.0"
domhandler "^5.0.3"
dunder-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==
dependencies:
call-bind-apply-helpers "^1.0.1"
es-errors "^1.3.0"
gopd "^1.2.0"
eastasianwidth@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
@@ -3069,23 +3020,11 @@ error-stack-parser@^2.0.6:
dependencies:
stackframe "^1.3.4"
es-define-property@^1.0.0, es-define-property@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa"
integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==
es-errors@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz#a2d0b373205724dfa525d23b0c3e1b1ca582c99b"
integrity sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==
dependencies:
es-errors "^1.3.0"
escalade@^3.1.1, escalade@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5"
@@ -3524,13 +3463,6 @@ find-up@^7.0.0:
path-exists "^5.0.0"
unicorn-magic "^0.1.0"
find-yarn-workspace-root@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz#f47fb8d239c900eb78179aa81b66673eac88f7bd"
integrity sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==
dependencies:
micromatch "^4.0.2"
flow-enums-runtime@^0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz#5bb0cd1b0a3e471330f4d109039b7eba5cb3e787"
@@ -3554,15 +3486,6 @@ fresh@~0.5.2:
resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==
fs-extra@^10.0.0:
version "10.1.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf"
integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==
dependencies:
graceful-fs "^4.2.0"
jsonfile "^6.0.1"
universalify "^2.0.0"
fs.realpath@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
@@ -3588,35 +3511,11 @@ get-caller-file@^2.0.5:
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
get-intrinsic@^1.2.4, get-intrinsic@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01"
integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==
dependencies:
call-bind-apply-helpers "^1.0.2"
es-define-property "^1.0.1"
es-errors "^1.3.0"
es-object-atoms "^1.1.1"
function-bind "^1.1.2"
get-proto "^1.0.1"
gopd "^1.2.0"
has-symbols "^1.1.0"
hasown "^2.0.2"
math-intrinsics "^1.1.0"
get-package-type@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a"
integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==
get-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1"
integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==
dependencies:
dunder-proto "^1.0.1"
es-object-atoms "^1.0.0"
get-stream@^5.1.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3"
@@ -3662,11 +3561,6 @@ glob@^7.1.1, glob@^7.1.3, glob@^7.1.4:
once "^1.3.0"
path-is-absolute "^1.0.0"
gopd@^1.0.1, gopd@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==
got@^11.5.1:
version "11.8.6"
resolved "https://registry.yarnpkg.com/got/-/got-11.8.6.tgz#276e827ead8772eddbcfc97170590b841823233a"
@@ -3684,7 +3578,7 @@ got@^11.5.1:
p-cancelable "^2.0.0"
responselike "^2.0.0"
graceful-fs@^4.1.11, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.9:
graceful-fs@^4.2.4, graceful-fs@^4.2.9:
version "4.2.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
@@ -3706,19 +3600,7 @@ has-flag@^4.0.0:
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
has-property-descriptors@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854"
integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==
dependencies:
es-define-property "^1.0.0"
has-symbols@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338"
integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==
hasown@^2.0.2, hasown@^2.0.3:
hasown@^2.0.3:
version "2.0.4"
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003"
integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==
@@ -3959,11 +3841,6 @@ is-wsl@^2.1.1, is-wsl@^2.2.0:
dependencies:
is-docker "^2.0.0"
isarray@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723"
integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==
isexe@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
@@ -4133,36 +4010,11 @@ [email protected]:
resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==
json-stable-stringify@^1.0.2:
version "1.3.0"
resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz#8903cfac42ea1a0f97f35d63a4ce0518f0cc6a70"
integrity sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==
dependencies:
call-bind "^1.0.8"
call-bound "^1.0.4"
isarray "^2.0.5"
jsonify "^0.0.1"
object-keys "^1.1.1"
json5@^2.2.3:
version "2.2.3"
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
jsonfile@^6.0.1:
version "6.2.1"
resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.2.1.tgz#b6e31717f22cc37330b081ce0051ed5de53af2f6"
integrity sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==
dependencies:
universalify "^2.0.0"
optionalDependencies:
graceful-fs "^4.1.6"
jsonify@^0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978"
integrity sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==
keyv@^4.0.0:
version "4.5.4"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
@@ -4170,13 +4022,6 @@ keyv@^4.0.0:
dependencies:
json-buffer "3.0.1"
klaw-sync@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/klaw-sync/-/klaw-sync-6.0.0.tgz#1fd2cfd56ebb6250181114f0a581167099c2b28c"
integrity sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==
dependencies:
graceful-fs "^4.1.11"
kleur@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
@@ -4356,11 +4201,6 @@ marky@^1.2.2:
resolved "https://registry.yarnpkg.com/marky/-/marky-1.3.0.tgz#422b63b0baf65022f02eda61a238eccdbbc14997"
integrity sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==
math-intrinsics@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
[email protected]:
version "2.0.14"
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50"
@@ -4580,7 +4420,7 @@ [email protected], metro@^0.83.6:
ws "^7.5.10"
yargs "^17.6.2"
micromatch@^4.0.2, micromatch@^4.0.4:
micromatch@^4.0.4:
version "4.0.8"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
@@ -4658,11 +4498,6 @@ [email protected]:
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
integrity sha512-7Wl+Jz+IGWuSdgsQEJ4JunV0si/iMhg42MnQQG6h1R6TNeVenp4U9x5CC5v/gYqz/fENLQITAWXidNtVL0NNbw==
minimist@^1.2.6:
version "1.2.8"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2, minipass@^7.1.3:
version "7.1.3"
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b"
@@ -4787,11 +4622,6 @@ object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
object-keys@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
on-finished@~2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
@@ -4825,7 +4655,7 @@ onetime@^2.0.0:
dependencies:
mimic-fn "^1.0.0"
open@^7.0.3, open@^7.4.2:
open@^7.0.3:
version "7.4.2"
resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321"
integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==
@@ -4939,26 +4769,6 @@ parseurl@~1.3.3:
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
patch-package@^8.0.1:
version "8.0.1"
resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-8.0.1.tgz#79d02f953f711e06d1f8949c8a13e5d3d7ba1a60"
integrity sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==
dependencies:
"@yarnpkg/lockfile" "^1.1.0"
chalk "^4.1.2"
ci-info "^3.7.0"
cross-spawn "^7.0.3"
find-yarn-workspace-root "^2.0.0"
fs-extra "^10.0.0"
json-stable-stringify "^1.0.2"
klaw-sync "^6.0.0"
minimist "^1.2.6"
open "^7.4.2"
semver "^7.5.3"
slash "^2.0.0"
tmp "^0.2.4"
yaml "^2.2.2"
path-exists@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
@@ -5554,7 +5364,7 @@ semver@^6.3.0, semver@^6.3.1:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
semver@^7.1.3, semver@^7.3.5, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0:
semver@^7.1.3, semver@^7.3.5, semver@^7.5.4, semver@^7.6.0:
version "7.8.5"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69"
integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==
@@ -5593,18 +5403,6 @@ serve-static@^1.16.2:
parseurl "~1.3.3"
send "~0.19.1"
set-function-length@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449"
integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==
dependencies:
define-data-property "^1.1.4"
es-errors "^1.3.0"
function-bind "^1.1.2"
get-intrinsic "^1.2.4"
gopd "^1.0.1"
has-property-descriptors "^1.0.2"
setimmediate@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
@@ -5668,11 +5466,6 @@ sisteransi@^1.0.5:
resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed"
integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==
slash@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44"
integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==
slash@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
@@ -5937,11 +5730,6 @@ tmp@^0.0.33:
dependencies:
os-tmpdir "~1.0.2"
tmp@^0.2.4:
version "0.2.7"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.7.tgz#26f4db11d1601ce8012dcb8a798ece1c06a99059"
integrity sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==
[email protected]:
version "1.0.5"
resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc"
@@ -6032,11 +5820,6 @@ unicorn-magic@^0.1.0:
resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.1.0.tgz#1bb9a51c823aaf9d73a8bfcd3d1a23dde94b0ce4"
integrity sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==
universalify@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d"
integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==
unpipe@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
@@ -6247,7 +6030,7 @@ yaml@^1.10.0:
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.3.tgz#76e407ed95c42684fb8e14641e5de62fe65bbcb3"
integrity sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==
yaml@^2.2.2, yaml@^2.6.1:
yaml@^2.6.1:
version "2.9.0"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.0.tgz#78274afd93598a1dfdd6130df6a566defcbf9aa4"
integrity sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==