Author SHA1 Message Date
harukin-expo-dev-env 7990361d04 Merge commit '96477296fa9e6de24c10a6d83a949c25599348e9' into feature/migrate-api 2026-08-10 01:57:46 +00:00
harukin-expo-dev-env 96477296fa fix: CommonTypesからeachTrainDiagramTypeをインポートするように修正 2026-08-10 01:57:34 +00:00
harukin-expo-dev-env 4b5a8d44fb Merge commit 'ab71fab73b87dad991262c2e6fcbf1036020dcc8' into develop 2026-08-10 01:50:10 +00:00
harukin-expo-dev-env 26090ff486 feat: Android列車位置通知をR2へ移行 2026-07-30 15:24:41 +00:00
harukin-expo-dev-env 11cbb586be docs: areaInfoの観察方針を記録 2026-07-30 15:24:33 +00:00
harukin-expo-dev-env a3707f2970 feat: 運行情報をR2スナップショットへ移行 2026-07-30 15:24:23 +00:00
harukin-expo-dev-env be69c365ef Merge commit 'ff8313ee42834d115fe0f5f4865a166766cfc094' into develop 2026-07-30 08:12:35 +00:00
harukin-expo-dev-env ff8313ee42 feat: 音声案内テキストの全角スペースを適切な読点に置換し、発車時刻表の表示を改善 2026-07-30 08:12:22 +00:00
harukin-expo-dev-env 22ce82a799 feat: りっかちゃん音声案内基盤を拡張 2026-07-29 05:31:35 +00:00
harukin-expo-dev-env cae1030bea docs: Voicepeak公開APIの要件を追加 2026-07-29 05:30:41 +00:00
harukin-expo-dev-env 3511d27726 docs: バックグラウンドりっかちゃんの構成案を追加 2026-07-29 05:30:16 +00:00
harukin-expo-dev-env d16bae0d45 feat: 運行情報表示を更新 2026-07-29 05:29:12 +00:00
harukin-expo-dev-env 37c3a80483 Merge commit '5df00c8b1766969c3c71154733dc4fa098fd8468' into experimental/station-announce-tools 2026-07-15 08:04:41 +00:00
harukin-expo-dev-env 34542c35c3 Merge commit 'dd0b4bf312f8f5d255f7cb5a0bc27d8bda43b5e9' into experimental/station-announce-tools 2026-07-01 12:11:25 +00:00
harukin-expo-dev-env fed1044089 Voicepeak APIの案内音声再生タイミングを変更 2026-06-29 04:51:21 +00:00
harukin-expo-dev-env 2220f0c524 Merge commit '5efa63254d2c0e1d8090b14190b2ed43b9ed4937' into experimental/station-announce-tools 2026-06-27 06:45:04 +00:00
harukin-expo-dev-env 3c5b403fb5 Merge commit '65f248f658482f2aa64fb0246a33ea7d19c72bad' into experimental/station-announce-tools 2026-06-25 17:47:42 +00:00
harukinMBP d27b75cbd3 feat: Voicepeak発車案内機能の強化とネイティブオーディオサポートの追加 2026-06-06 17:47:29 +09:00
harukinMBP 08147b7f26 feat: Voicepeak発車案内機能の追加と設定管理の実装 2026-06-06 17:47:29 +09:00
37 changed files with 5560 additions and 460 deletions
+5
View File
@@ -39,6 +39,7 @@ import {
startAppLifecycleCrashSentinel,
stopAppLifecycleCrashSentinel,
} from "./lib/observability/appLifecycleCrashSentinel";
import { migrateLegacyVoicepeakSettings } from "./lib/migrateLegacyVoicepeakSettings";
Sentry.init({
dsn: 'https://1090312e4cf501f5a455d523eff2d538@o4511646874664960.ingest.us.sentry.io/4511646880432128',
@@ -122,6 +123,10 @@ export default Sentry.wrap(function App() {
UpdateAsync();
}, []);
useEffect(() => {
void migrateLegacyVoicepeakSettings();
}, []);
useEffect(() => {
const openFelicaPage = (retryCount = 0) => {
if (!rootNavigationRef.isReady()) {
+4 -7
View File
@@ -24,7 +24,7 @@
"**/*"
],
"ios": {
"buildNumber": "66",
"buildNumber": "67",
"supportsTablet": true,
"bundleIdentifier": "jrshikokuinfo.xprocess.hrkn",
"appleTeamId": "54CRDT797G",
@@ -41,10 +41,7 @@
],
"ITSAppUsesNonExemptEncryption": false,
"NSSupportsLiveActivities": true,
"NSSupportsLiveActivitiesFrequentUpdates": true,
"UIBackgroundModes": [
"audio"
]
"NSSupportsLiveActivitiesFrequentUpdates": true
},
"entitlements": {
"com.apple.developer.nfc.readersession.formats": [
@@ -57,7 +54,7 @@
},
"android": {
"package": "jrshikokuinfo.xprocess.hrkn",
"versionCode": 32,
"versionCode": 33,
"intentFilters": [
{
"action": "VIEW",
@@ -134,7 +131,7 @@
[
"expo-location",
{
"locationWhenInUsePermission": "この位置情報は、リンク画面で現在地近の駅情報を取得するのに使用されます。"
"locationWhenInUsePermission": "現在地近の駅表示と、列車追従中に次の停車駅への接近をりっかちゃん音声で通知するために使用ます。"
}
],
[
+15 -12
View File
@@ -6,21 +6,24 @@ 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 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);
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("^");
return { time, text };
};
@@ -259,7 +259,7 @@ export const FixedStation: FC<props> = ({ stationID }) => {
const player = delayAnnouncementPlayerRef.current;
setAudioModeAsync({
playsInSilentMode: true,
shouldPlayInBackground: true,
shouldPlayInBackground: false,
interruptionMode: "duckOthers",
})
.then(() => {
@@ -332,10 +332,14 @@ export const FixedStation: FC<props> = ({ stationID }) => {
}, [selectedTrain, currentTrain, liveNotifyId, buildTrainsInfo]);
// バナー表示と同時にLive Activityを自動開始(selectedTrainが揃ってから)
// iOSのみ一時的に無効化中(Androidは有効)
useEffect(() => {
// iOSのLive Activityは無効化
if (Platform.OS === 'ios') return;
if (
!isLiveActivityAvailable() ||
hasStartedRef.current ||
station.length === 0
) {
return;
}
hasStartedRef.current = true;
const startActivity = async () => {
if (Platform.OS === 'android' && Platform.Version >= 33) {
@@ -34,7 +34,15 @@ import {
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;
@@ -98,6 +106,9 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
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>(
@@ -603,10 +614,15 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
]);
// バナー表示と同時にLive Activityを自動開始
// iOSのみ一時的に無効化中(Androidは有効)
useEffect(() => {
// iOSのLive Activityは無効化
if (Platform.OS === 'ios') return;
if (
!isLiveActivityAvailable() ||
hasStartedRef.current ||
!train ||
!nextStopStationData[0]
) {
return;
}
hasStartedRef.current = true;
const startActivity = async () => {
if (Platform.OS === 'android' && Platform.Version >= 33) {
@@ -641,6 +657,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
setLiveNotifyId(id);
} catch (e) {
console.warn('[LiveNotify] start error:', e);
hasStartedRef.current = false;
}
};
startActivity();
@@ -661,6 +678,155 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
currentStationIndex,
]);
// iOSへ残り停車駅の位置トリガーとりっかちゃん通知音を登録する。
useEffect(() => {
if (Platform.OS !== "ios" || playbackCurrentTimeIso) return;
const upcomingStations = allStations
.slice(currentStationIndex + 1)
.map((entry, offset) => ({ entry, offset }))
.filter(({ entry }) => entry.isStop)
.map(({ entry, offset }) => {
const stationData = getStationDataFromName(entry.name)[0];
if (
!stationData ||
!Number.isFinite(stationData.lat) ||
!Number.isFinite(stationData.lng)
) {
return null;
}
return {
identifier: `${currentStationIndex + 1 + offset}-${stationData.StationNumber || entry.name}`,
stationName: entry.name,
latitude: stationData.lat,
longitude: stationData.lng,
};
})
.filter((station): station is NonNullable<typeof station> => station != null)
.slice(0, 20);
if (upcomingStations.length === 0) return;
const signature = [
backgroundRikkaTrackingId,
currentStationIndex,
...upcomingStations.map((station) => station.identifier),
].join(":");
if (backgroundRikkaSignatureRef.current === signature) return;
const controller = new AbortController();
Promise.all([
AS.getItem(STORAGE_KEYS.BACKGROUND_RIKKA_ANNOUNCEMENT).catch(() => "false"),
AS.getItem(STORAGE_KEYS.BACKGROUND_RIKKA_TRIGGER_SOURCE).catch(
() => DEFAULT_BACKGROUND_RIKKA_TRIGGER_SOURCE
),
])
.then(async ([value, source]) => {
const enabled = value === true || value === "true";
if (
!enabled ||
source === "trainPosition" ||
controller.signal.aborted
) {
backgroundRikkaSignatureRef.current = "";
await cancelLocationAnnouncements(backgroundRikkaTrackingId);
return;
}
backgroundRikkaSignatureRef.current = signature;
const result = await prepareBackgroundRikkaAnnouncements({
trackingId: backgroundRikkaTrackingId,
stations: upcomingStations,
signal: controller.signal,
});
console.info(
`[BackgroundRikka] scheduled=${result.scheduled} failed=${result.failedStations.length}`
);
})
.catch((error) => {
backgroundRikkaSignatureRef.current = "";
if (!controller.signal.aborted) {
console.warn("[BackgroundRikka] Failed to schedule announcements", error);
}
});
return () => controller.abort();
}, [
allStations,
backgroundRikkaTrackingId,
currentStationIndex,
getStationDataFromName,
playbackCurrentTimeIso,
]);
// 検証用: 列車走行位置から算出した次駅が変わった瞬間に通知する。
useEffect(() => {
if (Platform.OS !== "ios") return;
const stationName = nextStopStationData[0]?.Station_JP;
if (!stationName) return;
const controller = new AbortController();
Promise.all([
AS.getItem(STORAGE_KEYS.BACKGROUND_RIKKA_ANNOUNCEMENT).catch(() => "false"),
AS.getItem(STORAGE_KEYS.BACKGROUND_RIKKA_TRIGGER_SOURCE).catch(
() => DEFAULT_BACKGROUND_RIKKA_TRIGGER_SOURCE
),
])
.then(async ([enabledValue, source]) => {
const enabled =
enabledValue === true || enabledValue === "true";
if (
!enabled ||
source !== "trainPosition" ||
controller.signal.aborted
) {
lastTrainPositionAnnouncementRef.current = "";
return;
}
const announcementKey = `${trainID}:${stationName}`;
if (
lastTrainPositionAnnouncementRef.current === announcementKey
) {
return;
}
lastTrainPositionAnnouncementRef.current = announcementKey;
await cancelLocationAnnouncements(backgroundRikkaTrackingId);
if (controller.signal.aborted) return;
await sendTrainPositionRikkaAnnouncement({
stationName,
trainId: trainID,
signal: controller.signal,
});
})
.catch((error) => {
lastTrainPositionAnnouncementRef.current = "";
if (!controller.signal.aborted) {
console.warn(
"[BackgroundRikka] Train-position announcement failed",
error
);
}
});
return () => controller.abort();
}, [
backgroundRikkaTrackingId,
nextStopStationData,
train?.Pos,
trainID,
]);
useEffect(() => {
return () => {
cancelLocationAnnouncements(backgroundRikkaTrackingId).catch(() => {});
};
}, [backgroundRikkaTrackingId]);
return (
<View
style={{ display: "flex", flexDirection: "column", flex: 1 }}
+215 -6
View File
@@ -1,5 +1,11 @@
import React, { useCallback, useEffect, useState } from "react";
import { View, Text, ScrollView, Platform } from "react-native";
import {
View,
Text,
ScrollView,
Platform,
TouchableOpacity,
} from "react-native";
import { Switch } from "@rneui/themed";
import { useNavigation } from "@react-navigation/native";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
@@ -9,13 +15,26 @@ 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);
@@ -54,11 +73,34 @@ export const SoundSettings = () => {
const previewPlayer = useAudioPlayer(resolvedSource);
useEffect(() => {
AS.getItem(STORAGE_KEYS.SOUND_DELAY_ANNOUNCEMENT)
.then((v) => setDelayAnnouncement(v === true || v === "true"))
.catch(() => {
// 未設定時はデフォルト値 false のまま
});
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
);
}
);
}, []);
const playPreview = useCallback(async () => {
@@ -85,6 +127,26 @@ export const SoundSettings = () => {
}
};
const handleVoicepeakToggle = (value: boolean) => {
setVoicepeakEnabled(value);
AS.setItem(STORAGE_KEYS.VOICEPEAK_ENABLED, value.toString());
};
const handleBackgroundRikkaToggle = (value: boolean) => {
setBackgroundRikkaEnabled(value);
AS.setItem(
STORAGE_KEYS.BACKGROUND_RIKKA_ANNOUNCEMENT,
value.toString()
);
};
const handleBackgroundRikkaSource = (
value: BackgroundRikkaTriggerSource
) => {
setBackgroundRikkaSource(value);
AS.setItem(STORAGE_KEYS.BACKGROUND_RIKKA_TRIGGER_SOURCE, value);
};
return (
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
<SheetHeaderItem
@@ -112,6 +174,153 @@ 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>
);
@@ -0,0 +1,214 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Text, TouchableOpacity, View } from "react-native";
import { setAudioModeAsync, useAudioPlayer } from "expo-audio";
import { WebView } from "react-native-webview";
import { useThemeColors } from "@/lib/theme";
import {
requestVoicepeakSpeechBytes,
VoicepeakRequestError,
} from "@/lib/voicepeak";
import {
createVoicepeakAudioSource,
type PreparedVoicepeakAudio,
} from "@/lib/voicepeakAudioSource";
import type { VoicepeakDebugLogEntry } from "@/lib/voicepeakDebugLog";
type DebugAudioAction = "fetch" | "force";
export const VoicepeakDebugAudioActions = ({
log,
onComplete,
}: {
log: VoicepeakDebugLogEntry;
onComplete?: () => void | Promise<void>;
}) => {
const { colors, fixed } = useThemeColors();
const player = useAudioPlayer(null);
const [activeAction, setActiveAction] = useState<DebugAudioAction | null>(
null,
);
const [nativeAudio, setNativeAudio] = useState<{
html: string;
key: number;
} | null>(null);
const requestControllerRef = useRef<AbortController | null>(null);
const cleanupAudioRef = useRef<(() => void) | undefined>(undefined);
const stopCurrentAudio = useCallback(() => {
try {
player.pause();
} catch {
// Player may not have a source yet.
}
setNativeAudio(null);
cleanupAudioRef.current?.();
cleanupAudioRef.current = undefined;
}, [player]);
useEffect(
() => () => {
requestControllerRef.current?.abort();
stopCurrentAudio();
},
[log.id, stopCurrentAudio],
);
const playAudio = useCallback(
async (audio: PreparedVoicepeakAudio) => {
stopCurrentAudio();
cleanupAudioRef.current =
audio.kind === "expo-audio" ? audio.cleanup : undefined;
await setAudioModeAsync({
playsInSilentMode: true,
shouldPlayInBackground: false,
interruptionMode: "duckOthers",
});
if (audio.kind === "native-webview") {
setNativeAudio({ html: audio.html, key: Date.now() });
return;
}
player.replace(audio.source);
player.volume = 1;
await player.seekTo(0);
player.play();
},
[player, stopCurrentAudio],
);
const runAction = useCallback(
async (force: boolean) => {
if (activeAction) return;
const action: DebugAudioAction = force ? "force" : "fetch";
const controller = new AbortController();
requestControllerRef.current = controller;
setActiveAction(action);
try {
const bytes = await requestVoicepeakSpeechBytes({
text: log.text,
settings: { enabled: true },
signal: controller.signal,
format: log.format,
force,
});
if (controller.signal.aborted) return;
const audio = await createVoicepeakAudioSource(bytes, log.format);
if (controller.signal.aborted) {
if (audio.kind === "expo-audio") audio.cleanup?.();
return;
}
await playAudio(audio);
await onComplete?.();
} catch (error) {
const aborted =
controller.signal.aborted ||
(error instanceof VoicepeakRequestError && error.code === "ABORTED");
if (!aborted) {
const message =
error instanceof VoicepeakRequestError
? `${error.message}\n\nHTTP: ${error.status || "-"}\nCode: ${
error.code
}\nRequest ID: ${error.requestId || "-"}`
: error instanceof Error
? error.message
: String(error);
Alert.alert(
force ? "音声の再作成に失敗しました" : "音声の取得に失敗しました",
message,
);
}
} finally {
if (requestControllerRef.current === controller) {
requestControllerRef.current = null;
}
setActiveAction(null);
}
},
[activeAction, log.format, log.text, onComplete, playAudio],
);
const disabled = activeAction !== null;
const borderColor = colors.borderSecondary ?? "#ccc";
return (
<View style={{ marginTop: 14 }}>
<Text
style={{
marginBottom: 8,
color: colors.textSecondary ?? colors.text,
fontSize: 12,
lineHeight: 17,
}}
>
使
</Text>
<View style={{ flexDirection: "row", gap: 8 }}>
<TouchableOpacity
accessibilityRole="button"
disabled={disabled}
onPress={() => void runAction(false)}
style={{
flex: 1,
paddingVertical: 11,
borderRadius: 8,
borderWidth: 1,
borderColor: fixed.primary,
opacity: disabled ? 0.55 : 1,
}}
>
<Text
style={{
color: fixed.primary,
textAlign: "center",
fontWeight: "600",
}}
>
{activeAction === "fetch" ? "取得中…" : "取得・再生"}
</Text>
</TouchableOpacity>
<TouchableOpacity
accessibilityRole="button"
disabled={disabled}
onPress={() => void runAction(true)}
style={{
flex: 1,
paddingVertical: 11,
borderRadius: 8,
backgroundColor: disabled ? borderColor : fixed.primary,
opacity: disabled ? 0.55 : 1,
}}
>
<Text
style={{ color: "#fff", textAlign: "center", fontWeight: "600" }}
>
{activeAction === "force" ? "再作成中…" : "再作成して再生"}
</Text>
</TouchableOpacity>
</View>
{nativeAudio && (
<WebView
key={nativeAudio.key}
source={{ html: nativeAudio.html }}
originWhitelist={["*"]}
javaScriptEnabled
scrollEnabled={false}
mediaPlaybackRequiresUserAction={false}
allowsInlineMediaPlayback
onMessage={() => setNativeAudio(null)}
style={{
position: "absolute",
width: 1,
height: 1,
opacity: 0,
}}
/>
)}
</View>
);
};
@@ -0,0 +1,373 @@
import React, { useCallback, useState } from "react";
import {
Alert,
Modal,
Pressable,
ScrollView,
Text,
TouchableOpacity,
View,
} from "react-native";
import * as Clipboard from "expo-clipboard";
import { useThemeColors } from "@/lib/theme";
import {
clearVoicepeakDebugLogs,
getVoicepeakDebugLogs,
type VoicepeakDebugLogEntry,
} from "@/lib/voicepeakDebugLog";
import { VoicepeakDebugAudioActions } from "@/components/Settings/VoicepeakDebugAudioActions";
const formatDebugLog = (log: VoicepeakDebugLogEntry) =>
JSON.stringify(log, null, 2);
const formatAllDebugLogs = (logs: VoicepeakDebugLogEntry[]) =>
JSON.stringify(
{
exportedAt: new Date().toISOString(),
retentionDays: 7,
count: logs.length,
logs,
},
null,
2,
);
export const VoicepeakDebugLogSection = () => {
const { colors, fixed } = useThemeColors();
const [logs, setLogs] = useState<VoicepeakDebugLogEntry[]>([]);
const [selectedLog, setSelectedLog] = useState<VoicepeakDebugLogEntry | null>(
null,
);
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [expanded, setExpanded] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
setLogs(await getVoicepeakDebugLogs());
} catch (error) {
console.warn("Failed to load Voicepeak debug logs", error);
} finally {
setLoading(false);
}
}, []);
const toggleExpanded = useCallback(() => {
setExpanded((current) => {
const next = !current;
if (next) void refresh();
return next;
});
}, [refresh]);
const copyText = useCallback(async (text: string) => {
await Clipboard.setStringAsync(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, []);
const clearLogs = useCallback(() => {
Alert.alert(
"音声作成ログを削除",
"端末に保存されたVoicepeakデバッグ履歴をすべて削除します。",
[
{ text: "キャンセル", style: "cancel" },
{
text: "削除",
style: "destructive",
onPress: () => {
void clearVoicepeakDebugLogs().then(() => {
setLogs([]);
setSelectedLog(null);
});
},
},
],
);
}, []);
const borderColor = colors.borderSecondary ?? "#ccc";
const secondaryText = colors.textSecondary ?? colors.text;
return (
<View
style={{
paddingHorizontal: 15,
paddingVertical: 18,
borderBottomWidth: 1,
borderBottomColor: borderColor,
backgroundColor: colors.surface,
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
marginBottom: expanded ? 8 : 0,
}}
>
<TouchableOpacity
accessibilityRole="button"
accessibilityState={{ expanded }}
onPress={toggleExpanded}
style={{
flex: 1,
flexDirection: "row",
alignItems: "center",
paddingVertical: 2,
}}
>
<Text
style={{
flex: 1,
fontSize: 16,
fontWeight: "600",
color: colors.text,
}}
>
Voicepeak
{expanded ? `${logs.length}件)` : ""}
</Text>
<Text style={{ color: fixed.primary, padding: 8 }}>
{expanded ? "閉じる ▲" : "表示する ▼"}
</Text>
</TouchableOpacity>
{expanded && (
<TouchableOpacity onPress={() => void refresh()}>
<Text style={{ color: fixed.primary, padding: 8 }}>
{loading ? "読込中" : "更新"}
</Text>
</TouchableOpacity>
)}
</View>
{expanded && (
<>
<Text
style={{
fontSize: 12,
lineHeight: 18,
color: secondaryText,
marginBottom: 12,
}}
>
APIへ送信したテキストHTTP結果
APIトークンは保存しません7
</Text>
<View style={{ flexDirection: "row", gap: 8, marginBottom: 12 }}>
<TouchableOpacity
disabled={logs.length === 0}
onPress={() => void copyText(formatAllDebugLogs(logs))}
style={{
flex: 1,
paddingVertical: 10,
borderRadius: 8,
backgroundColor: logs.length > 0 ? fixed.primary : borderColor,
}}
>
<Text
style={{
color: "#fff",
textAlign: "center",
fontWeight: "600",
}}
>
{copied ? "コピーしました" : "全履歴をコピー"}
</Text>
</TouchableOpacity>
<TouchableOpacity
disabled={logs.length === 0}
onPress={clearLogs}
style={{
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 8,
borderWidth: 1,
borderColor,
}}
>
<Text
style={{ color: logs.length > 0 ? "#d32f2f" : secondaryText }}
>
</Text>
</TouchableOpacity>
</View>
{logs.length === 0 ? (
<Text style={{ color: secondaryText, paddingVertical: 10 }}>
</Text>
) : (
logs.slice(0, 200).map((log) => {
const statusColor =
log.status === "success"
? "#2e7d32"
: log.status === "error"
? "#d32f2f"
: "#ed6c02";
return (
<TouchableOpacity
key={log.id}
onPress={() => setSelectedLog(log)}
style={{
paddingVertical: 11,
borderTopWidth: 1,
borderTopColor: borderColor,
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
marginBottom: 5,
}}
>
<Text style={{ color: statusColor, fontWeight: "700" }}>
{log.status === "success"
? "成功"
: log.status === "error"
? "失敗"
: "処理中"}
</Text>
<Text
style={{
flex: 1,
textAlign: "right",
color: secondaryText,
fontSize: 12,
}}
>
{new Date(log.createdAt).toLocaleString("ja-JP")}
</Text>
</View>
<Text
numberOfLines={2}
style={{ color: colors.text, fontSize: 13, lineHeight: 18 }}
>
{log.text}
</Text>
<Text
style={{
color: secondaryText,
fontSize: 11,
marginTop: 5,
}}
>
{log.codePointCount ?? Array.from(log.text).length}
{log.chunkIndex && log.totalChunks
? ` ・ 分割 ${log.chunkIndex}/${log.totalChunks}`
: ""}
{log.attemptNumber ? ` ・ 試行 ${log.attemptNumber}` : ""}
{typeof log.httpStatus === "number"
? ` ・ HTTP ${log.httpStatus}`
: ""}
{log.errorCode ? `${log.errorCode}` : ""}
{log.cacheStatus ? `${log.cacheStatus}` : ""}
{typeof log.durationMilliseconds === "number"
? `${log.durationMilliseconds}ms`
: ""}
</Text>
</TouchableOpacity>
);
})
)}
</>
)}
<Modal
visible={selectedLog !== null}
transparent
animationType="fade"
onRequestClose={() => setSelectedLog(null)}
>
<Pressable
onPress={() => setSelectedLog(null)}
style={{
flex: 1,
justifyContent: "center",
padding: 20,
backgroundColor: "rgba(0,0,0,0.55)",
}}
>
<Pressable
onPress={(event) => event.stopPropagation()}
style={{
maxHeight: "85%",
padding: 16,
borderRadius: 12,
backgroundColor: colors.surface,
}}
>
<Text
style={{
fontSize: 17,
fontWeight: "700",
color: colors.text,
marginBottom: 12,
}}
>
Voicepeakログ詳細
</Text>
{selectedLog && (
<VoicepeakDebugAudioActions
log={selectedLog}
onComplete={refresh}
/>
)}
<ScrollView>
<Text
selectable
style={{
color: colors.text,
fontSize: 12,
lineHeight: 18,
fontFamily: "monospace",
}}
>
{selectedLog ? formatDebugLog(selectedLog) : ""}
</Text>
</ScrollView>
<View style={{ flexDirection: "row", gap: 8, marginTop: 14 }}>
<TouchableOpacity
onPress={() =>
selectedLog && void copyText(formatDebugLog(selectedLog))
}
style={{
flex: 1,
paddingVertical: 11,
borderRadius: 8,
backgroundColor: fixed.primary,
}}
>
<Text
style={{
color: "#fff",
textAlign: "center",
fontWeight: "600",
}}
>
{copied ? "コピーしました" : "詳細をコピー"}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setSelectedLog(null)}
style={{
paddingHorizontal: 18,
paddingVertical: 11,
borderRadius: 8,
borderWidth: 1,
borderColor,
}}
>
<Text style={{ color: colors.text }}></Text>
</TouchableOpacity>
</View>
</Pressable>
</Pressable>
</Modal>
</View>
);
};
+441 -6
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo, FC } from "react";
import { View, useWindowDimensions, Text } from "react-native";
import React, { useState, useEffect, useMemo, FC, useCallback, useRef } from "react";
import { View, useWindowDimensions, Text, Platform } from "react-native";
import { useCurrentTrain } from "@/stateBox/useCurrentTrain";
import { useAreaInfo } from "@/stateBox/useAreaInfo";
import { AS } from "@/storageControl";
@@ -9,12 +9,35 @@ import { EachData } from "@/components/発車時刻表/EachData";
import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
import { AreaDescription } from "@/components/発車時刻表/LED_inside_Component/AreaDescription";
import { getTime, trainTimeFiltering } from "@/lib/trainTimeFiltering";
import { StationProps } from "@/lib/CommonTypes";
import type { 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 {
@@ -56,10 +79,35 @@ 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 } = useNavigation();
const { navigate, addListener } = useNavigation();
const { currentTrain } = useCurrentTrain();
const { stationList } = useStationList();
const { playbackCurrentTimeIso } = useTrainMenu();
@@ -68,10 +116,45 @@ export const LED_vision: FC<props> = (props) => {
const [trainDescriptionSwitch, setTrainDescriptionSwitch] = useState(false);
const [isInfoArea, setIsInfoArea] = useState(false);
const { areaInfo, areaStationID } = useAreaInfo();
const { allTrainDiagram } = useAllTrainDiagram();
const { allTrainDiagram, allCustomTrainData } = 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"),
@@ -81,7 +164,27 @@ 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]);
const currentStation = station[0];
const stationDiagram = useMemo<{ [key: string]: string }>(() => {
@@ -115,6 +218,312 @@ export const LED_vision: FC<props> = (props) => {
.filter((d) => !!finalSwitch || d.lastStation != currentStation.Station_JP); //最終列車表示設定
}, [currentStation, currentTrain, finalSwitch, playbackCurrentTimeIso, station, stationList, trainTimeAndNumber]);
const getVoicepeakCandidates = useCallback(() => {
if (!currentTrain?.length || !allCustomTrainData) return [];
const activeTrainNumbers = new Set(currentTrain.map((train) => train.num));
const candidateTrains = new Map(
selectedTrain.map((train) => [train.train, train])
);
trainTimeAndNumber.forEach((train) => {
if (train.isThrough && activeTrainNumbers.has(train.train)) {
candidateTrains.set(train.train, train);
}
});
return [...candidateTrains.values()]
.flatMap((train) => {
const currentTrainData = getCurrentTrainData(
train.train,
currentTrain,
allCustomTrainData
);
const currentTrainStatuses = currentTrain.filter(
(currentTrainItem) => currentTrainItem.num === train.train
);
const currentTrainStatus =
currentTrainStatuses.length > 1
? checkDuplicateTrainData(currentTrainStatuses, stationList) ??
currentTrainStatuses[0]
: currentTrainStatuses[0];
const position = currentTrainStatus
? trainPosition(currentTrainStatus)
: null;
const isStoppedAtStation =
position?.isBetween === false &&
position.Pos.Pos === station[0].Station_JP;
const delayMinutes =
typeof currentTrainStatus?.delay === "number"
? currentTrainStatus.delay
: 0;
if (!currentTrainData) {
return [];
}
const getStage = (timingTrain: eachTrainDiagramType) =>
getVoicepeakAnnouncementStage({
station: station[0],
train: timingTrain,
currentTrainData,
delayMinutes,
});
const buildCandidate = (
timingTrain: eachTrainDiagramType,
stage: NonNullable<ReturnType<typeof getVoicepeakAnnouncementStage>>,
advanceTimeBasis: "arrival" | "departure" = "departure"
): VoicepeakCandidate => ({
key: buildVoicepeakAnnouncementKey(station[0], timingTrain, stage),
text: buildVoicepeakAnnouncementText({
station: station[0],
train: timingTrain,
currentTrainData,
stage,
delayMinutes,
isOrigin: timingTrain.isOrigin === true,
isStoppedAtStation,
advanceTimeBasis,
}),
departureTime: timingTrain.time,
priority:
stage === "passing" ? 0 : stage === "departure" ? 1 : 2,
});
if (train.arrivalTime && train.departureTime) {
const candidates: VoicepeakCandidate[] = [];
const arrivalTimingTrain = { ...train, time: train.arrivalTime };
const arrivalStage = getStage(arrivalTimingTrain);
if (arrivalStage === "advance" || arrivalStage === "departure") {
candidates.push(
buildCandidate(arrivalTimingTrain, "advance", "arrival")
);
}
const departureTimingTrain = { ...train, time: train.departureTime };
const departureStage = getStage(departureTimingTrain);
const dwellMinutes = getDwellMinutes(
train.arrivalTime,
train.departureTime
);
if (departureStage === "departure") {
candidates.push(
buildCandidate(departureTimingTrain, "departure")
);
} else if (
departureStage === "advance" &&
dwellMinutes !== null &&
dwellMinutes >= 3
) {
candidates.push(buildCandidate(departureTimingTrain, "advance"));
}
return candidates;
}
const stage = getStage(train);
return stage ? [buildCandidate(train, stage)] : [];
})
.sort(
(a, b) =>
a.priority - b.priority ||
a.departureTime.localeCompare(b.departureTime)
);
}, [
allCustomTrainData,
currentTrain,
selectedTrain,
station,
stationList,
trainTimeAndNumber,
]);
const finishVoicepeakPlayback = useCallback(() => {
isExpoVoicepeakPlayingRef.current = false;
isNativeVoicepeakPlayingRef.current = false;
cleanupAudioRef.current?.();
cleanupAudioRef.current = undefined;
pendingVoicepeakAudiosRef.current.forEach((audio) => {
if (audio.kind === "expo-audio") audio.cleanup?.();
});
pendingVoicepeakAudiosRef.current = [];
isVoicepeakBusyRef.current = false;
if (isVoicepeakMountedRef.current) {
drainVoicepeakQueueRef.current();
}
}, []);
const startVoicepeakAudio = useCallback(
async (audio: PreparedVoicepeakAudio) => {
cleanupAudioRef.current?.();
cleanupAudioRef.current =
audio.kind === "expo-audio" ? audio.cleanup : undefined;
await setAudioModeAsync({
playsInSilentMode: true,
shouldPlayInBackground: false,
interruptionMode: "duckOthers",
});
if (audio.kind === "native-webview") {
isNativeVoicepeakPlayingRef.current = true;
setNativeVoicepeakHtml(audio.html);
setNativeVoicepeakPlaybackKey((current) => current + 1);
} else {
announcementPlayer.replace(audio.source);
announcementPlayer.volume = 1;
await announcementPlayer.seekTo(0);
isExpoVoicepeakPlayingRef.current = true;
announcementPlayer.play();
}
},
[announcementPlayer]
);
useEffect(() => {
startVoicepeakAudioRef.current = startVoicepeakAudio;
}, [startVoicepeakAudio]);
const completeVoicepeakAudioSegment = useCallback(() => {
isExpoVoicepeakPlayingRef.current = false;
isNativeVoicepeakPlayingRef.current = false;
cleanupAudioRef.current?.();
cleanupAudioRef.current = undefined;
const nextAudio = pendingVoicepeakAudiosRef.current.shift();
if (!nextAudio || !isVoicepeakMountedRef.current) {
finishVoicepeakPlayback();
return;
}
void startVoicepeakAudioRef.current(nextAudio).catch((error) => {
console.warn("Failed to play Voicepeak announcement segment", error);
finishVoicepeakPlayback();
});
}, [finishVoicepeakPlayback]);
const playVoicepeakAnnouncementBatch = useCallback(
async (candidates: VoicepeakCandidate[]) => {
if (!voicepeakSettings || !hasVoicepeakConfiguration(voicepeakSettings)) {
candidates.forEach((candidate) => {
reservedAnnouncementKeysRef.current.delete(candidate.key);
});
isVoicepeakBusyRef.current = false;
return;
}
const combinedText = candidates
.map((candidate) => candidate.text)
.join("続いて、");
const controller = new AbortController();
currentRequestRef.current = controller;
try {
const audios = await requestVoicepeakSpeeches({
text: combinedText,
settings: voicepeakSettings,
signal: controller.signal,
});
if (!isVoicepeakMountedRef.current) {
audios.forEach((audio) => {
if (audio.kind === "expo-audio") audio.cleanup?.();
});
return;
}
const [firstAudio, ...remainingAudios] = audios;
if (!firstAudio) {
throw new Error("Voicepeak returned no announcement audio");
}
pendingVoicepeakAudiosRef.current = remainingAudios;
await startVoicepeakAudio(firstAudio);
candidates.forEach((candidate) => {
announcedKeysRef.current.add(candidate.key);
reservedAnnouncementKeysRef.current.delete(candidate.key);
});
} catch (error) {
candidates.forEach((candidate) => {
reservedAnnouncementKeysRef.current.delete(candidate.key);
if (!controller.signal.aborted) {
announcedKeysRef.current.add(candidate.key);
}
});
if (!controller.signal.aborted) {
console.warn("Failed to play Voicepeak announcement", error);
}
finishVoicepeakPlayback();
} finally {
if (currentRequestRef.current === controller) {
currentRequestRef.current = null;
}
}
},
[finishVoicepeakPlayback, startVoicepeakAudio, voicepeakSettings]
);
const drainVoicepeakQueue = useCallback(() => {
if (
isVoicepeakBusyRef.current ||
queuedAnnouncementsRef.current.size === 0
) {
return;
}
const candidates = [...queuedAnnouncementsRef.current.values()].sort(
(a, b) =>
a.priority - b.priority ||
a.departureTime.localeCompare(b.departureTime)
);
queuedAnnouncementsRef.current.clear();
isVoicepeakBusyRef.current = true;
void playVoicepeakAnnouncementBatch(candidates);
}, [playVoicepeakAnnouncementBatch]);
useEffect(() => {
drainVoicepeakQueueRef.current = drainVoicepeakQueue;
}, [drainVoicepeakQueue]);
useEffect(() => {
if (
isExpoVoicepeakPlayingRef.current &&
announcementPlayerStatus.didJustFinish
) {
completeVoicepeakAudioSegment();
}
}, [
announcementPlayerStatus.didJustFinish,
completeVoicepeakAudioSegment,
]);
const checkVoicepeakAnnouncement = useCallback(() => {
if (!voicepeakSettings || !hasVoicepeakConfiguration(voicepeakSettings)) {
return;
}
getVoicepeakCandidates().forEach((candidate) => {
if (
announcedKeysRef.current.has(candidate.key) ||
reservedAnnouncementKeysRef.current.has(candidate.key)
) {
return;
}
reservedAnnouncementKeysRef.current.add(candidate.key);
queuedAnnouncementsRef.current.set(candidate.key, candidate);
});
drainVoicepeakQueue();
}, [drainVoicepeakQueue, getVoicepeakCandidates, voicepeakSettings]);
useEffect(() => {
checkVoicepeakAnnouncement();
}, [checkVoicepeakAnnouncement]);
useInterval(() => {
checkVoicepeakAnnouncement();
}, 1000);
const { width } = useWindowDimensions();
const adjustedWidth = width * 0.98;
return (
@@ -127,6 +536,32 @@ 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,6 +10,9 @@ 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,6 +109,24 @@ 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',
@@ -0,0 +1,591 @@
# API構成・通信負荷レビュー 2026-07-29
## 結論
現状の重さは、単純な「APIレスポンスが遅い」だけではなく、次の4点が重なって発生している。
1. 同じ大容量データをReact Native側と走行位置WebView側が別々に30秒ごとに取得している。
2. 起動時は通常ポーリングとは別に、同じ取得が短時間に2回以上発生する経路がある。
3. 更新がなくても全件JSONを再取得し、展開・JSON parse・全件走査・JSON stringify・state更新を繰り返している。
4. n8n、Google Apps Script、静的ストレージ、バックエンドAPI、公式Webサイトへ端末が直接接続し、キャッシュ・fallback・データ結合を各端末側で担当している。
最優先は、バックエンドを単なるデータ取得先ではなく「端末向けBFFBackend for Frontend)」にして、データ取得の所有者をReact Native側の1か所へ集約すること。そのうえで、WebViewには取得済みデータを注入する。
バックエンド側では、データの更新頻度を以下の3階層に分けるのがよい。
- live: 走行位置、運行情報サマリー。5〜15秒単位、差分または304。
- operational: 運行ログ、外部運用情報。30〜60秒単位、差分または対象列番だけ。
- daily/static: 当日ダイヤ、列車マスタ、駅マスタ。バージョンが変わったときだけ取得。
この構成にすると、アプリは「複数hostの取得・fallback・結合・キャッシュ」を持たず、`live snapshot``versioned resources` を表示するだけになる。
---
## 調査範囲
- `App.tsx` の全Provider
- `stateBox` 配下のデータ取得
- `lib/webViewInjectjavascript.ts` のWebView内fetchとlocalStorage cache
- 走行位置・運行情報の起動時prewarm
- 列車詳細、発車標、駅詳細、Android Widgetの個別fetch
- API endpoint、ポーリング間隔、timeout、retry、fallback
- 2026-07-29時点の公開endpointに対するGET実測
今回変更したのは本レポートだけで、アプリ実装は変更していない。
---
## 現在の通信構造
```text
アプリ起動
├─ React Native Providers
│ ├─ n8n: current positions15秒)
│ ├─ backend-api: train-data30秒)
│ ├─ backend-api: operation-logs30秒)
│ ├─ data-storage: diagram-today30秒)
│ ├─ n8n + GAS: operation flag/text60秒)
│ ├─ GAS: delay, train-pair, bus/train
│ └─ backend-api: permission
├─ 走行位置WebView
│ ├─ JR四国公式ページ/XHR
│ ├─ backend-api: train-data30秒)
│ ├─ backend-api: operation-logs30秒)
│ ├─ data-storage: diagram-today30秒)
│ ├─ n8n: station-list / position-problems
│ └─ data-storage: unyohub / elesite(有効時30秒)
└─ 運行情報WebView
└─ JR四国公式ページ
```
走行位置WebViewが表示タブでなくても保持されるため、React Native Providerの通信とWebViewの通信が同時に継続する。
---
## 実測したレスポンス
2026-07-29 10:39〜10:42 UTCに1回ずつ計測した参考値。時間はネットワーク状況で変動する。
| データ | endpoint | JSON/本文サイズ | zstd交渉時の転送量 | 件数 | 参考応答時間 |
|---|---|---:|---:|---:|---:|
| 列車マスタ/カスタム列車 | `/train-data` | 1,218,990 B | 72,498 B | 1,851 | 0.12〜0.21秒 |
| 当日ダイヤ | `/tmp/diagram-today.json` | 667,584 B | 120,278 B | 1,333 | 0.16〜0.52秒 |
| 運行ログ | `/operation-logs` | 64,162 B | 9,508 B | 115 | 0.08秒 |
| 現在位置 | n8n positions | 13,887 B | 2,328 B | 105 | 0.14〜0.46秒 |
| 駅リスト | n8n station-list | 82,561 B | 11,389 B | 9路線 | 0.17〜0.49秒 |
| UnyoHub | static JSON | 957,331 B | 62,048 B | 220 | 0.16〜0.56秒 |
| えれサイト | static JSON | 867,937 B | 29,588 B | 374 | 0.14〜0.59秒 |
| 運行情報本文 | GAS | 小容量 | 20 B(今回) | - | 2.16秒 |
| 遅延情報本文 | GAS | 小容量 | 20 B(今回) | - | 1.70秒 |
| バス・列車データ | GAS | 小容量 | 1,436 B | - | 2.52秒 |
| 列車ペア | GAS | 小容量 | 512 B | - | 1.68秒 |
### HTTP cacheの状態
- Cloudflare圧縮は有効であり、転送時の圧縮不足は主問題ではない。
- 今回確認した主要endpointには `Cache-Control` がなかった。
- `/train-data``/operation-logs``cf-cache-status: DYNAMIC` で、確認したレスポンスにはETagがなかった。
- static JSONにはETag/Last-Modifiedがあるが、`cf-cache-status: DYNAMIC` だった。
- UnyoHub/えれサイトはクライアントが毎回 `?_=timestamp` を付けるため、ETagや通常のHTTP cacheを実質利用できない。
圧縮後の転送量が小さくても、端末では毎回1.2MB等へ展開してJSON parseする。WebView側はさらに全体を `JSON.stringify` して変更判定とlocalStorage保存を行う。
---
## 概算負荷
### 通常時
走行位置WebViewが一度起動して保持されている前提では、主要3データだけで以下が継続する。
| 実行場所 | 30秒ごとの対象 | 1分あたりの展開後JSON |
|---|---|---:|
| React Native | train-data + diagram + operation-logs | 約3.90MB |
| WebView | train-data + diagram + operation-logs | 約3.90MB |
| React Native | positions15秒) | 約0.06MB |
| 合計 | - | 約7.86MB/分 |
1時間表示すると、変更有無に関係なく約472MB分のJSONを両JS runtimeで処理する計算になる。zstd相当の圧縮が使われた場合でも、参考転送量は約0.82MB/分、約49MB/時。
UnyoHubとえれサイトを両方有効にすると、WebViewだけでさらに約3.65MB/分の展開・parseが増える。参考転送量は約0.18MB/分増える。React Native側の同名hookが複数mountされると、その分も追加される。
### 起動時
トップメニュー起動から走行位置WebViewの初期化までに、主要JSONだけで概算約6MBが展開・parseされる。これには公式WebViewのHTML、CSS、JavaScript、画像、公式XHR、GAS、n8nの補助APIは含めていない。
理由:
- `getCurrentTrain()` は初回effectと `mockApiFeatureEnabled` effectの両方がmount時に走る。
- WebViewのPhase 1/2で主要APIを取得した直後、`startPolling()` が初回待機なしで同じAPIを再取得する。
- React Native Providerも同じ主要APIを取得済み。
---
## 無駄な通信と判断した要素
### P0: React NativeとWebViewの二重取得
対象:
- `/train-data`
- `/operation-logs`
- `/tmp/diagram-today.json`
React Native側は `stateBox/useAllTrainDiagram.tsx` で30秒ごと、WebView側は `lib/webViewInjectjavascript.ts` で30秒ごとに取得する。
同じアプリプロセス内にWebView bridgeがあるため、通信の所有者をReact Native側に統一し、WebViewへ差分注入できる。これだけで主要3データの通信・parse・hash処理をほぼ半減できる。
### P0: WebView初期取得直後の即時再取得
WebViewはPhase 1/2で以下を取得する。
- station-list
- operation-logs
- position-problems
- train-data
- diagram-today
その完了直後に `startPolling()` を呼び、その中で初回fetchを即実行するため、station-list以外の4データを短時間にもう一度取得する。
ポーリング開始時は最初の30秒を待つか、Phase 1/2の取得時刻を初回ポーリング成功として扱うべき。
### P0: 起動時positionsの二重取得
`stateBox/useCurrentTrain.tsx` は以下の2 effectがmount時に両方実行される。
- dependency `[]` の初回取得
- dependency `[mockApiFeatureEnabled]` の切替時取得
結果として通常位置情報を2本同時に開始し得る。n8n失敗時は各リクエストがretry後にGAS fallbackへ進むため、不調時に通信が増幅する。
### P0: 非表示タブのWebView prewarm重複
トップメニュー起動時は、1pxのhidden positions/operation WebViewを読み込む。同時に500ms後にはpositions/information root自体もprewarmされ、実WebViewをmountする。
hidden WebViewの読み込みが完了前に実WebViewへ切り替わると、公式ページの初期通信を2回行ったうえ、hidden側のwarm-up結果を実WebView instanceへ引き継げない。
hidden preloadと実root preloadのどちらか一方に統一すべき。
### P0: 全件データを更新有無に関係なく30秒取得
特に `/train-data` は1,851件、約1.22MB。多くの `updated_at` は分単位で変わる性質ではないが、30秒ごとに全件取得している。
`diagram-today` も当日中の変更頻度に対して30秒全件取得は過剰。ETag/If-None-Match、バージョンmanifest、immutable URLのいずれかが必要。
### P0: UnyoHub/えれサイトの多重ポーラー
`useUnyohub()``useElesite()` は共有Contextではなく、hookを呼ぶコンポーネントごとに独立state・独立intervalを作る。
確認できた呼び出し箇所:
- StationDiagramView
- StationDiagram/ListView
- TrainDataSources
- EachTrainInfoCore/HeaderText
さらにWebViewも同じデータを30秒ごとに取得する。React Native hookは10分間隔だが、mount数に比例して増える。
両データは約0.96MB、約0.87MBあるため、1つのProvider/BFF queryへ集約する必要がある。
### P0: cache busterによるHTTP cache無効化
UnyoHub/えれサイトはReact Native/WebViewの両方で `?_=Date.now()` を付ける。static JSONにはETagがあるため、これは既存のcache能力を捨てている。
更新確認には `If-None-Match` またはversion manifestを使うべきで、timestamp queryは削除対象。
### P1: retry時間がpoll間隔を超え、リクエストが重なる
React Nativeの主要fetchはtimeout 15秒、最大1 retry、retry前wait 0.75〜1.25秒。最悪約31秒かかる一方、poll間隔は30秒。
positionsはtimeout 8秒 + retryで最悪約17秒、poll間隔は15秒。
`useInterval` にin-flight guardがないため、不調時に次周期が開始される。バックエンドが遅いほど端末とサーバー双方へ追加負荷をかける。
### P1: 表示詳細ごとのn8n GET
位置ID補助情報は少なくとも以下2 componentから同じendpointへ取得される。
- `TrainDataView`
- LED `TrainPosition`
`TrainDataView``currentTrainData` object全体をdependencyにしているため、15秒ごとのpositions更新で同じ位置でも再取得し得る。
このメタデータはpositionsレスポンスへ結合するか、BFF側の `position_meta_by_key` として一括配信する方がよい。
### P1: アンパンマン列車statusの再取得
`TrainIconStatus` のn8n fetchは、列番だけでなく `allCustomTrainData``todayOperation``iconDisplayMode` の変更でも再実行される。前2つは30秒ごとに新しい配列へ置き換わる。
同じ列番を複数rowで表示する場合も各componentが個別取得する。日次列車メタデータかlive train contextへstatusを含めるべき。
### P1: 小容量だが遅いGASへの直接接続
今回の実測ではGASの小容量responseに1.7〜2.5秒かかった。
- operation text
- delay text
- bus/train
- train pair
- positions fallback
データ量の問題ではなく、接続先と実行基盤の応答時間が支配的。バックエンドがGASを定期取得・cacheし、端末には同一originから即時返す方がよい。
### P2: 駅住所の外部LOD API取得
駅詳細表示時に `jslodApi.json` を直接取得する。駅住所は更新頻度が極めて低いため、駅マスタへ含めるかアプリassetにするべき。
### P2: 使用されていないendpoint定数
`constants/api.ts` の以下は現在のアプリコードから参照されていない。
- `CUSTOM_TRAIN_DATA`
- `DELAY_INFO`
- `SPECIAL_TRAIN_INFO`
旧endpointを残すならdeprecated表示と削除予定を明記し、そうでなければ削除してAPI inventoryを単純化する。
---
## バックエンド側で簡略化すべき要素
### 1. 端末向けBFFを1 originに集約
推奨origin例:
```text
https://jr-shikoku-backend-api-v2.haruk.in
```
BFFが以下を吸収する。
- n8n
- GAS
- static data storage
- mock/productionの環境差
- upstream timeout/retry
- last-known-good cache
- response schema normalization
- source freshness
端末は各upstream URLを知らず、BFFだけを呼ぶ。
### 2. 更新頻度別にAPIを分離
#### `GET /v2/bootstrap`
小容量の起動manifestと現在のlive summaryだけを返す。
```json
{
"schema_version": 2,
"generated_at": "2026-07-29T10:40:00Z",
"live": {
"cursor": "live-...",
"positions": [],
"operation_summary": {
"badge": "",
"affected_station_ids": [],
"text": "",
"is_information": false
}
},
"resources": {
"daily": {
"version": "2026-07-29.abc123",
"url": "/v2/resources/daily/2026-07-29.abc123.json"
},
"station_master": {
"version": "station.def456",
"url": "/v2/resources/stations/station.def456.json"
}
}
}
```
bootstrap自体へ1〜3MBを詰め込むのではなく、version確認とlive初期表示に限定する。
#### `GET /v2/live`
対象:
- positions
- operation summary
- 必要ならoperation log delta
`If-None-Match` または `?since=<cursor>` を受け、変更なしなら304/204を返す。
#### versioned daily resource
対象:
- diagram by train id
- train metadata by train id
- train-pair
- special train metadata
- アンパンマン列車の当日情報
URL自体にversionを含め、`Cache-Control: public, max-age=31536000, immutable` を付ける。更新時はbootstrapのversionだけ変える。
#### `GET /v2/train-context?train_ids=...`
対象列番だけについて以下を一括で返す。
- custom train metadata
- operations
- UnyoHub summary/entries
- えれサイト summary/entries
- position metadata
一覧画面ではviewport内の列番をbatch指定する。1列番ごとのN+1 fetchは禁止する。
### 3. 配列ではなく検索済みindexを返す
現在は端末側で以下を繰り返している。
- 1,851件のtrain-dataから `find(train_id)`
- operation logs全件から列番をsplit/map/includes
- UnyoHub/えれサイト全件から列番をfilter
- diagram配列をobjectへ変換・sort
推奨response:
```json
{
"train_meta_by_id": {
"1M": {}
},
"diagram_by_train_id": {
"1M": "..."
},
"operations_by_train_id": {
"1M": []
},
"source_summary_by_train_id": {
"1M": {
"unyohub": [],
"elesite": []
}
}
}
```
これによりアプリ側の全件scan、index作成、同一データの複数表現を削減できる。
### 4. operation flagと本文を1 responseに統合
現在はn8n flag取得後、影響駅がある場合にGAS textを追加取得する。
BFFの `operation_summary` が以下を返せば1回で済む。
- badge text
- is_information
- affected areas
- affected station IDs
- display text
- upstream updated_at
- stale状態
### 5. positionsへ位置メタデータを結合
`PosNum + Line + StationName` で別n8nへ問い合わせているplatform/line/descriptionをpositionsへ結合する。
レスポンス肥大化が気になる場合は、重複値を `position_meta_by_key` にまとめる。
### 6. permission APIをquery tokenから分離
現在はExpo push tokenを `GET /check-permission?user_id=...` に入れている。
これはURL、proxy log、edge log、Sentry自動HTTP span等へ残りやすく、cacheもしにくい。以下のいずれかへ変更する。
- `Authorization` header
- POST body
- push tokenとは別のinstallation IDを発行
permissionは公開data APIと別cache policyにする。
---
## むしろ追加すべき要素
### P0: ETag / Cache-Control / conditional GET
推奨例:
| データ | Cache-Control案 |
|---|---|
| live positions | `private, max-age=5, stale-while-revalidate=30` |
| operation summary/logs | `public, max-age=15, stale-while-revalidate=300` |
| third-party summary | `public, max-age=60, stale-while-revalidate=600` |
| versioned daily/station resource | `public, max-age=31536000, immutable` |
| bootstrap manifest | `no-cache` + ETag |
`no-cache` は「保存禁止」ではなく再検証を要求する指定として使う。
### P0: server-side last-known-good
upstream障害時に全端末がn8n→GAS fallbackを実行するのではなく、BFFが1回だけfallbackし、全端末へ最後の成功データを返す。
レスポンスに以下を含める。
```json
{
"generated_at": "...",
"source_updated_at": "...",
"stale": true,
"stale_age_seconds": 42,
"source": "last_known_good"
}
```
表示継続できるデータは200 + stale metadataで返し、保持データすらない場合だけ503にする。
### P0: upstream collector
端末リクエストのたびにGAS/n8n/JR公式へ取りに行かず、scheduled worker等がupstreamを1回取得してcacheへ保存する。
```text
upstream collector
├─ JR公式/n8n/GASを所定間隔で取得
├─ schema検証
├─ normalize/index作成
├─ last-good保存
└─ version/cursor更新
mobile BFF
└─ collector結果を低遅延で返す
```
### P1: schema versionとruntime validation
各responseへ `schema_version` を付ける。collector側で期待schemaを検証し、HTML/error pageや壊れたJSONをcacheへ昇格させない。
### P1: データ鮮度・cache hitの観測
最低限、サーバー側で以下を記録する。
- endpoint
- total duration
- upstream duration
- cache hit/miss/stale
- upstream status
- payload bytes
- item count
- source_updated_at / stale_age
- conditional requestの304率
- active client/version
追加header例:
```text
X-Data-Age: 12
X-Cache-Status: HIT
X-Source: n8n
X-Schema-Version: 2
```
### P1: change cursor / delta
positionsやoperation logは全件再送ではなく、cursor以降の変更を返せるとよい。
```json
{
"cursor": "next-cursor",
"upserts": [],
"deletes": []
}
```
最初はETag + 304だけでも効果が大きい。deltaは第2段階でよい。
### P2: invalidation通知
daily resourceや運行情報が変わったときだけ、既存のpush基盤で「version changed」を通知し、アプリが次回foreground時に再検証する方式を検討できる。
常時WebSocket/SSEはbackground、再接続、電池消費の複雑性が増すため、最初の解決策にはしない。
---
## 推奨する責務分担
| 処理 | 現在 | 推奨 |
|---|---|---|
| upstream retry/fallback | 各端末 | BFF/collector |
| stale cache | 一部memory/localStorage/AsyncStorage | BFF last-good + 端末persistent cache |
| data join | RNとWebViewの双方 | collector |
| train id index | 各component/各runtime | backend response |
| operation area→station展開 | RN hardcode | BFF operation summary |
| WebView用データ取得 | WebView自身 | RN取得結果をbridge注入 |
| environment切替 | RNの一部のみ | bootstrap origin/configで統一 |
| 更新確認 | 30秒全件GET | version/ETag/cursor |
| optional source取得 | 複数hook + WebView | train-context batch |
---
## 実装優先順位
### Phase 0: アプリだけで止められる無駄
1. `getCurrentTrain()` のmount時二重実行を1回にする。
2. WebView Phase 1/2後は30秒待ってからpollする。
3. hidden WebView preloadとroot preloadを一方だけにする。
4. 非表示positions WebViewのpollを停止する。
5. UnyoHub/えれサイトhookを共有Providerへ統合する。
6. timestamp cache busterを外す。
7. native pollへsingle-flightを入れる。
この段階だけでも起動時と定常時の重複は大幅に減る。
### Phase 1: 既存backendをcache frontにする
1. `/train-data`, `/operation-logs`, positions, GAS系にserver-side cacheを導入。
2. ETag/Cache-Control/304を追加。
3. last-known-goodとfreshness metadataを追加。
4. server-side timing/cache hitの観測を追加。
5. static resourceをversioned immutable URLへ移行。
アプリのresponse schemaを大きく変えずに導入できる。
### Phase 2: BFF v2
1. `/v2/bootstrap`
2. `/v2/live`
3. versioned daily/station resource
4. `/v2/train-context?train_ids=...`
5. operation summary統合
6. indexed response
### Phase 3: WebViewの通信所有権を廃止
React NativeがBFFから受け取ったsnapshot/deltaをWebViewへ注入する。既存のmock XHR interceptorがあるため、公式WebViewへ位置情報を渡す技術的な土台はすでに存在する。
---
## 期待効果
保守的に見ても以下を狙える。
- 主要3データの定常通信・JSON処理を、RN/WebView二重取得の解消だけで約50%削減。
- unchanged時に304を使えば、さらに本文転送・JSON parse・state更新をほぼゼロにできる。
- `train-data` とdaily diagramをversion変更時だけにすれば、30秒周期の約1.89MB展開処理を両runtimeから除去できる。
- 起動時の約6MB主要JSON処理を、live bootstrap + cache済みversion resource中心へ置き換えられる。
- GAS/n8n障害時の端末側retry stormを防止できる。
- アプリ側は複数host、fallback、area展開、全件join、複数cacheを持たずに済む。
最終的な目標は「30秒ごとに全部取り直すアプリ」から、「変更通知された小さなsnapshot/deltaを1か所で受け取るアプリ」への移行である。
---
## 主な根拠箇所
- 全Provider mount: `App.tsx`
- RN主要3データの30秒poll: `stateBox/useAllTrainDiagram.tsx`
- positionsのmount時二重取得と15秒poll: `stateBox/useCurrentTrain.tsx`
- operation flag→GAS text: `stateBox/useAreaInfo.tsx`
- WebView主要API、cache、即時poll: `lib/webViewInjectjavascript.ts`
- hidden preloadとroot preload: `Apps.tsx`
- UnyoHub/えれサイトの独立hook: `stateBox/useUnyohub.tsx`, `stateBox/useElesite.tsx`
- 位置ID補助N+1: `components/ActionSheetComponents/EachTrainInfo/TrainDataView.tsx`, `components/発車時刻表/LED_inside_Component/TrainPosition.tsx`
- アンパンマンstatus再取得: `components/ActionSheetComponents/EachTrainInfoCore/trainIconStatus.tsx`
- permission query: `stateBox/useTrainMenu.tsx`
+522
View File
@@ -0,0 +1,522 @@
# バックグラウンドりっかちゃん通知・Live Activity バックエンド連携計画(素案)
> ステータス: 相談用ドラフト
> 作成日: 2026-07-19
> 目的: n8n・Expo Push・APNs・ActivityKitを利用し、アプリが前面にいない状態でも列車追従アナウンスとDynamic Island更新を成立させる。
## 1. 結論
既存の通知システムは大部分を再利用できる。
- 通知対象の登録、購読リスト管理、列車位置の監視、送信判定はn8nを継続利用する。
- 通常の表示通知は、まず既存のExpo Push経路を利用する。
- Dynamic IslandLive Activity)のリモート更新だけは、ActivityKit専用Push Tokenを使い、n8nまたは専用APIからAPNsへ直接送信する。
- 端末で動的生成したりっかちゃん音声をExpo Push経由で再生できるかは、先に実機PoCで確認する。
- Expo経由で動的通知音が安定しない場合は、通常通知もAPNs直接送信へ切り替える。
想定する最終構成は次のとおり。
```text
アプリ
├─ ExpoPushToken ───────────────┐
├─ ActivityKit Push Token ─────┤
├─ 追従列車・運行日・有効期限 ─┤
└─ 端末に準備済みの音声一覧 ──┤
n8n / DB
列車走行位置情報API
次駅変化・到着接近を判定
┌─────────────────┴─────────────────┐
↓ ↓
Expo Push Service APNs直接送信
通常の表示・音声通知 Live Activity更新
↓ ↓
「次は、○○です。」 Dynamic Island更新
```
## 2. 実現したいユーザー体験
### 2.1 列車追従モード
1. ユーザーがアプリで列車を選び、列車追従を開始する。
2. 追従開始時に、経路上の駅について「次は、○○です。」の音声を端末内へ準備する。
3. アプリは追従条件とPush Tokenをバックエンドへ登録する。
4. アプリがバックグラウンドまたは終了状態でも、バックエンドが列車走行位置を監視する。
5. 次駅が変化したとき、表示通知とりっかちゃん音声を配信する。
6. 開始済みのLive Activityが存在する場合、Dynamic Islandも同じ状態へ更新する。
7. 終着、日付変更、ユーザー操作、有効期限切れのいずれかで追従を終了する。
### 2.2 駅固定モード
駅固定モードも同じ購読基盤へ載せられるが、最初のリリースでは列車追従モードを優先する。
将来は以下のイベントを対象にできる。
- 対象駅への列車接近
- 発車時刻または発車検知
- 一定以上の遅延発生
- 番線、行先、運休等の重要な変更
## 3. 「アプリを起動していない状態」の定義
状態によって実現方法が異なるため、仕様上は明確に区別する。
| アプリ状態 | 表示・音声Push通知 | 開始済みLive Activityの更新 | 新規Live Activity開始 |
|---|---:|---:|---:|
| フォアグラウンド | 可能 | 端末内更新・Push更新とも可能 | 可能 |
| バックグラウンド | 可能 | APNs Pushで可能 | 別途検討 |
| ユーザーがアプリを終了 | 原則可能 | Activityが存続中ならAPNs Pushで可能 | 初期版では対象外 |
| 端末再起動後 | 通知許可等に依存 | 既存Activityの状態に依存 | 初期版では対象外 |
初期版の「アプリを起動していない」は、**追従登録とLive Activity開始を一度アプリ上で行った後、アプリがバックグラウンドまたは終了状態になった場合**を指す。
バックエンドが表示通知を送る方式では、通知到着時にアプリのJavaScriptを起動する必要はない。そのため、本機能だけを理由にiOSの `UIBackgroundModes``audio``remote-notification` を追加しない。
## 4. 現在の実装と流用範囲
### 4.1 既に存在するもの
- `expo-notifications` によるExpo Push Token取得
- n8nを介した通知対象リストとExpo Push一斉送信の運用
- 列車追従・駅固定のLive Activityネイティブモジュール
- `NSSupportsLiveActivities``NSSupportsLiveActivitiesFrequentUpdates`
- Voicepeak APIによる駅名音声のWAV生成
- WAVをiOSの `Library/Sounds` へ保存するネイティブ処理
- 駅名から決定的な通知音ファイル名を作る処理
- 端末位置方式と列車走行位置方式の設定切替
### 4.2 現在の制限
- Live Activityは `pushType: nil` で開始され、ActivityKit Push Tokenを取得していない。
- 列車走行位置方式の次駅判定は、アプリのJavaScriptが動作して情報更新を受けている間しか安定して動かない。
- 動的生成した `Library/Sounds` 内のWAVを、Expo Push Serviceがカスタム通知音として確実にAPNsへ転送するか未検証。
- 通知登録に使うID、購読の有効期限、重複送信防止、Push Receipt処理の正式なデータモデルが未整備。
## 5. 配信経路の設計
### 5.1 通常通知
第一候補は既存経路を維持する。
```text
n8n → Expo Push Service → APNs → iPhone
```
送信例:
```json
{
"to": "ExponentPushToken[...]",
"title": "列車追従・りっかちゃん",
"body": "次は、坂出です。",
"sound": "rikka-next-1a2b3c.wav",
"priority": "high",
"ttl": 60,
"data": {
"schemaVersion": 1,
"type": "train-follow-announcement",
"subscriptionId": "sub_xxx",
"trainId": "123D",
"serviceDate": "2026-07-19",
"nextStation": "坂出",
"eventId": "123D:2026-07-19:next:坂出"
}
}
```
同じ駅名は全端末で同じファイル名にする。実際の音声内容は各端末がユーザーのVoicepeak設定で生成するため、サーバーは話者別の音声データを保持しなくてよい。
ただし、Pushを有効化する前に端末側で対象音声の保存完了を確認する。音声未準備の駅については、次のいずれかを仕様として選択する。
1. 通常の通知音へフォールバックする。
2. 音声なしで表示通知だけ送る。
3. 追従開始を失敗としてユーザーへ再試行を案内する。
初期案は **1の通常通知音フォールバック** とする。
### 5.2 Live Activity / Dynamic Island
ActivityKitのリモート更新はExpo Push Tokenではなく、Activity単位のPush Tokenを使用する。
```text
n8nまたはPush送信用API → APNs HTTP/2 → ActivityKit → Dynamic Island
```
必要なAPNsヘッダー:
```text
apns-push-type: liveactivity
apns-topic: <Bundle ID>.push-type.liveactivity
apns-priority: 5 または 10
```
更新例:
```json
{
"aps": {
"timestamp": 1784453400,
"event": "update",
"stale-date": 1784453520,
"content-state": {
"currentStation": "丸亀~宇多津",
"nextStation": "宇多津",
"delayMinutes": 3,
"scheduledArrival": "18:42",
"updatedAt": 1784453400
}
}
}
```
Live Activity終了時は `event: "end"` を送信し、最終表示内容とdismissal方針を定める。
### 5.3 Expo経由の動的音声が利用できない場合
通常通知もAPNs直接送信へ変更する。
```text
n8nまたはPush送信用API
├─ APNs alert push → 表示通知・Library/Sounds内の音声
└─ APNs liveactivity push → Dynamic Island
```
Appleの通知仕様では、通知音はアプリバンドルまたはアプリコンテナの `Library/Sounds` 内から指定できる。一方、Expoの公式なカスタムサウンド手順はビルド時設定を前提としているため、この分岐はPoC結果で決定する。
## 6. アプリからバックエンドへ登録する項目
### 6.1 追従購読
```json
{
"schemaVersion": 1,
"subscriptionId": "sub_xxx",
"installationId": "install_xxx",
"mode": "trainFollow",
"trainId": "123D",
"serviceDate": "2026-07-19",
"routeId": "yosan",
"destination": "高松",
"expoPushToken": "ExponentPushToken[...]",
"activity": {
"activityId": "activity_xxx",
"pushToken": "activitykit_token_hex",
"environment": "production"
},
"preparedSounds": {
"宇多津": "rikka-next-xxxx.wav",
"坂出": "rikka-next-yyyy.wav"
},
"createdAt": "2026-07-19T18:00:00Z",
"expiresAt": "2026-07-19T23:30:00Z"
}
```
### 6.2 必須項目
| 項目 | 用途 |
|---|---|
| `subscriptionId` | 購読の更新・停止・冪等性確保 |
| `installationId` | Expo TokenをユーザーIDとして扱わないための端末識別子 |
| `mode` | 列車追従・駅固定等の判別 |
| `trainId` | 監視対象列車 |
| `serviceDate` | 同じ列車番号の翌日混同防止 |
| `expoPushToken` | 通常通知の送信先 |
| `activity.pushToken` | Live Activity更新の送信先 |
| `preparedSounds` | 端末に存在する通知音名の確認 |
| `expiresAt` | 孤立した購読の自動削除 |
`activity.pushToken` は更新される可能性があるため、アプリは `pushTokenUpdates` を監視し、変化のたびにバックエンドへ再登録する。
## 7. バックエンドの状態モデル
購読とは別に、列車ごとの監視状態を保持する。
```json
{
"trainKey": "2026-07-19:123D",
"lastPosition": "丸亀~宇多津",
"currentStation": "丸亀",
"nextStation": "宇多津",
"delayMinutes": 3,
"lastSourceUpdatedAt": "2026-07-19T18:39:30Z",
"lastProcessedAt": "2026-07-19T18:39:31Z",
"lastEventId": "123D:2026-07-19:next:宇多津"
}
```
### 7.1 重複防止
通知判定は単なるポーリング回数ではなく、決定的な `eventId` で管理する。
```text
<serviceDate>:<trainId>:<eventType>:<stationId>
```
同一購読・同一 `eventId` の送信は一度だけとし、n8nの再実行やAPIの一時エラーによる重複アナウンスを防ぐ。
### 7.2 データ鮮度
列車位置情報が一定時間更新されていない場合、次駅変化を確定しない。
初期案:
- 位置情報の取得周期: 1530秒
- 情報の許容鮮度: 90秒
- 同じ変化を2回連続で観測した場合に確定。ただし情報源がイベント時刻や連番を持つ場合は再検討する。
- 終着または有効期限超過で監視終了
## 8. n8nワークフロー案
### Workflow A: 購読登録・更新
1. アプリから署名付きリクエストを受け取る。
2. スキーマ、通知許可、運行日、有効期限を検証する。
3. `subscriptionId` をキーにupsertする。
4. 同じ端末・同じ列車の古い購読を無効化する。
5. 登録結果とサーバー時刻を返す。
### Workflow B: 列車位置監視
1. 有効な購読を列車単位で集約する。
2. 同じ列車の位置情報は一度だけ取得する。
3. 前回状態と比較して、次駅・現在位置・遅延の変化を算出する。
4. データ鮮度と連続観測条件を検証する。
5. 新しい `eventId` をイベントキューへ登録する。
### Workflow C: 通常通知送信
1. イベントに該当する購読者を抽出する。
2. `preparedSounds[nextStation]` の有無を確認する。
3. 最大100件単位でExpo Pushへ送る。
4. Expo Push Ticketを保存する。
5. 後続処理でPush Receiptを取得する。
6. `DeviceNotRegistered` のExpo Tokenを無効化する。
7. 429・5xxは指数バックオフ付きで再試行する。
### Workflow D: Live Activity更新
1. ActivityKit Push Tokenを持つ購読だけ抽出する。
2. APNs JWTを生成または再利用する。
3. `liveactivity` 用ヘッダーと `content-state` を送る。
4. APNs応答を保存する。
5. 無効・期限切れTokenを無効化する。
6. 通常更新は優先度5、次駅変化等の主要イベントは必要に応じて10とする。
### Workflow E: 購読終了・清掃
以下の条件で購読を終了する。
- ユーザーが追従停止を操作
- 別列車へ追従対象を変更
- 列車が終着
- `expiresAt` 超過
- Push Tokenが無効
- 数時間にわたり列車データを取得できない
## 9. API素案
### `POST /v1/tracking-subscriptions`
追従開始。`subscriptionId` を指定した再送は冪等に処理する。
### `PATCH /v1/tracking-subscriptions/{subscriptionId}`
ActivityKit Push Token、Expo Push Token、準備済み音声一覧などを更新する。
### `DELETE /v1/tracking-subscriptions/{subscriptionId}`
追従停止。既存Live Activityをバックエンド側から終了させる必要がある場合は、削除前に `event: end` を送る。
### `POST /v1/tracking-subscriptions/{subscriptionId}/heartbeat`
初期版では必須にしない。将来、端末状態や購読継続確認が必要になった場合だけ追加する。
## 10. アプリ側の変更計画
### 10.1 ActivityKit対応
- `Activity.request(..., pushType: nil)``pushType: .token` へ変更する。
- `activity.pushTokenUpdates` を監視する。
- Activity IDとPush TokenをJSへ通知するExpo Module APIを追加する。
- Token変更、Activity終了、追従解除をバックエンドへ反映する。
- Live Activityの `ContentState` とAPNs `content-state` の型を完全一致させる。
### 10.2 追従登録
- 音声生成が完了した駅とファイル名を収集する。
- Expo Push Token、ActivityKit Push Token、列車情報を購読APIへ登録する。
- 登録中、登録済み、部分成功、失敗をUIで区別する。
- 追従停止時は購読削除を送る。通信失敗時はローカルに停止要求を保持して再試行する。
### 10.3 通知処理
- 通知タップ時に対象列車画面へ遷移する。
- `schemaVersion``type` を検証し、未知のPayloadを安全に無視する。
- フォアグラウンド受信時にも二重音声再生しない。
- 端末内通知音が欠損している場合の挙動を実機確認する。
## 11. APNs認証情報と運用
APNs直接送信には最低限、以下が必要になる。
- Apple DeveloperのAPNs Auth Key`.p8`
- Key ID
- Team ID
- Bundle ID
- development / production環境の識別
`.p8` と署名用秘密情報はアプリ、Git、n8nワークフロー定義へ直接埋め込まない。n8n Credentialsまたは専用のSecrets管理へ保存する。
n8nからAPNs HTTP/2とJWT処理を安定して扱えない場合、APNs送信だけを小さなCloudflare Worker、Lambda、Cloud Run等へ分離し、n8nはその内部APIを呼ぶ。
## 12. セキュリティ・プライバシー
- Expo Push Tokenを認証済みユーザーIDとして扱わない。
- `installationId` と購読用の署名または短期トークンを導入する。
- 登録APIをレート制限する。
- 他人の `subscriptionId` を推測して更新・削除できないようにする。
- Push Tokenをログ本文へそのまま出さず、必要なら末尾数文字のみ記録する。
- 保存する情報は追従に必要な列車、運行日、通知Tokenに限定する。
- 有効期限超過後は速やかに削除する。
## 13. 障害時の挙動
| 障害 | 推奨挙動 |
|---|---|
| 列車位置API停止 | 古い位置から通知せず、Live Activityをstale表示にする |
| Expo Push一時障害 | 指数バックオフで再試行。ただし次駅通知のTTL超過後は破棄 |
| APNs一時障害 | 短時間再試行し、次の状態更新で上書き可能にする |
| 音声未準備 | 通常音または音なしの表示通知へフォールバック |
| Activity Token無効 | Dynamic Island更新のみ停止し、通常通知は継続 |
| Expo Token無効 | 通常通知を停止し、購読を無効化または端末再登録待ちにする |
| 重複イベント | `eventId` と購読単位の送信履歴で抑止 |
| 位置情報が逆行・飛躍 | 連続観測と路線順序検証で確定を保留 |
## 14. 段階的な実装計画
### Phase 0: 技術PoC
- [ ] 実機で端末の `Library/Sounds` に動的WAVを保存する。
- [ ] n8nからExpo Pushの `sound` にそのファイル名を指定する。
- [ ] アプリが前面、背面、終了状態の3条件で音声再生を確認する。
- [ ] マナーモード、集中モード、Bluetooth、他アプリ音声再生中も確認する。
- [ ] Expo Push TicketとReceiptを保存・確認する。
- [ ] ActivityKit Push Tokenを取得する最小実装を作る。
- [ ] APNsから1回だけLive Activity更新を送る。
**判定ゲート:**
- 動的WAVがExpo経由で安定再生する → 通常通知はExpoを継続。
- 動的WAVが無音、デフォルト音、環境依存になる → 通常通知もAPNs直送へ変更。
### Phase 1: 列車追従MVP
- [ ] 列車追従購読APIを作成する。
- [ ] 列車単位の共有ポーリングと次駅変化判定を作る。
- [ ] `eventId` による重複防止を実装する。
- [ ] 「次は、○○です。」通常Push通知を実装する。
- [ ] 終着・停止・期限切れ処理を実装する。
- [ ] n8n上で送信状況を確認できるログを用意する。
### Phase 2: Dynamic Island連携
- [ ] Live Activityを `pushType: .token` で開始する。
- [ ] Token更新を購読APIへ同期する。
- [ ] APNs Live Activity更新処理を実装する。
- [ ] 次駅、現在位置、遅延、到着予定を同期する。
- [ ] stale、終了、Token無効時の処理を実装する。
### Phase 3: 駅固定モードと運用品質
- [ ] 駅固定購読を同じデータモデルへ追加する。
- [ ] 遅延・接近・発車イベントを追加する。
- [ ] 監視、メトリクス、失敗通知を整備する。
- [ ] 負荷試験とAPI障害試験を実施する。
- [ ] 不要Tokenと期限切れ購読の自動清掃を実装する。
## 15. テスト項目
### 15.1 通知音
- 対象WAVあり / なし
- アプリ前面 / 背面 / 終了
- 通常モード / マナーモード / 集中モード
- 端末スピーカー / Bluetooth / CarPlay相当環境
- 音楽や動画再生中
- 通知許可あり / サウンドのみ拒否 / 通知拒否
iOSのユーザー設定や集中モードをアプリ側から回避する設計にはしない。
### 15.2 列車位置判定
- 通常走行
- 長時間停車
- 遅延
- 位置情報欠落
- 位置の逆行または瞬間的な誤値
- 途中駅通過
- 列車番号重複と日付跨ぎ
- 併結、分割、列車番号変更がデータ上存在する場合
### 15.3 PushとLive Activity
- Token更新
- 無効Token
- 二重送信
- 順不同到着
- 古いPayload到着
- APNs 410等のエラー
- Activityがユーザーによって終了された場合
- 追従停止直後に送信イベントが競合した場合
## 16. App Review上の説明方針
本機能はバックグラウンドで継続的にオーディオ再生するものではない。列車位置判定はサーバー側で行い、利用者が明示的に登録した追従条件に対して、通常のユーザー通知として短い音声を再生する。
そのため、初期方針では `UIBackgroundModes = audio` を要求しない。Live ActivityはActivityKitとAPNsの正規手段で更新する。
Review Notesでは以下を簡潔に説明する。
- ユーザーが列車追従を明示的に開始・停止できること
- 通知と音声を設定画面で無効化できること
- 音声は通知到着時だけ短時間再生されること
- バックグラウンドで常時音声処理を行わないこと
- Dynamic IslandはActivityKit Pushで更新すること
## 17. 未決事項
別の設計レビューでは、特に以下を確認したい。
1. Expo Push経由で `Library/Sounds` の動的WAV指定が実運用上保証できるか。
2. n8nからAPNs HTTP/2へ直接送るか、署名・再試行を担当する小規模Push APIを分離するか。
3. 列車位置変化を「1回で確定」するか「2回連続観測で確定」するか。
4. 次駅アナウンスの発火点を、区間進入時、前駅発車時、次駅接近時のどこに置くか。
5. 音声準備に一部失敗した状態で追従開始を許可するか。
6. 駅固定モードをMVPへ含めるか、列車追従の安定後に追加するか。
7. APNs認証情報をn8nで保持するか、専用Push APIでのみ保持するか。
8. Live Activityの開始を常にアプリ操作必須とするか、将来Push-to-startを検討するか。
9. Voicepeak設定変更後、同じファイル名の音声をいつ再生成するか。
10. 列車走行位置情報APIの利用条件、更新間隔、障害時保証をどこまで前提にできるか。
## 18. 推奨する初期判断
- MVPは列車追従モードだけに絞る。
- n8nの既存購読リストとExpo一斉送信を維持する。
- Phase 0の実機PoCを最優先し、動的音声の配信経路を先に確定する。
- Dynamic IslandはExpo経由を試さず、最初からAPNs直接送信とする。
- APNs送信処理は将来の再利用性と秘密鍵管理を考え、可能ならn8n外の小規模APIへ分離する。
- アプリが閉じていても通知は成立させるが、初期版では追従開始そのものはアプリ操作を必須とする。
- `UIBackgroundModes = audio` は復活させない。
## 19. 参考資料
- [Apple: Generating a remote notification](https://developer.apple.com/documentation/usernotifications/generating-a-remote-notification)
- [Apple: Starting and updating Live Activities with ActivityKit push notifications](https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications)
- [Expo: Send notifications with the Expo Push Service](https://docs.expo.dev/push-notifications/sending-notifications/)
- [Expo: Notifications SDK](https://docs.expo.dev/versions/latest/sdk/notifications/)
- [既存のiOS Live Activity Push計画](./ios-live-activity-push-plan.md)
- [既存のサウンド機能計画](./sound-feature-plan.md)
+403 -162
View File
@@ -206,10 +206,14 @@ window.__TM_OPERATION_INFO_LAYOUT = Object.assign({
'.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:block !important;text-align:right !important;margin:12px 0 8px !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-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;}',
@@ -728,28 +732,23 @@ window.__TM_OPERATION_INFO_LAYOUT = Object.assign({
return bestText;
}
function buildXCoverSummary(items) {
var entries = (items || []).map(function(item) {
var title = strip(item && item.title) || '運行情報';
var subTitle = strip(item && item.subTitle);
return subTitle ? title + '' + subTitle : title;
}).filter(function(text) {
return !!text;
});
if (!entries.length) {
return '現在表示中の運行情報はありません。';
}
if (entries.length === 1) {
return entries[0];
}
if (entries.length === 2) {
return entries[0] + ' / ' + entries[1];
}
return entries.slice(0, 3).join(' / ') + (entries.length > 3 ? ' ほか' : '');
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) {
@@ -772,27 +771,41 @@ window.__TM_OPERATION_INFO_LAYOUT = Object.assign({
function createXDetailUnits(ctx, item, bodyWidth) {
var units = [];
var pendingHeading = '';
var sections = [];
var currentSection = { heading: '', body: [] };
var blocks = item && item.blocks ? item.blocks : [];
blocks.forEach(function(block) {
if (block.type === 'badge') {
pendingHeading = strip(block.text);
if (currentSection.heading || currentSection.body.length) {
sections.push(currentSection);
}
currentSection = { heading: strip(block.text), body: [] };
return;
}
ctx.font = "400 28px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
var bodyLines = wrapText(ctx, strip(block.text), bodyWidth);
if (!bodyLines.length && !pendingHeading) 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 = pendingHeading ? wrapText(ctx, pendingHeading, bodyWidth - 24) : [];
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,
headingText: pendingHeading,
lineHeight: 39
});
pendingHeading = '';
});
if (!units.length) {
@@ -800,7 +813,6 @@ window.__TM_OPERATION_INFO_LAYOUT = Object.assign({
units.push({
headingLines: [],
bodyLines: wrapText(ctx, '詳細情報はJR四国公式の運行情報をご確認ください。', bodyWidth),
headingText: '',
lineHeight: 39
});
}
@@ -808,6 +820,27 @@ window.__TM_OPERATION_INFO_LAYOUT = Object.assign({
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;
@@ -863,77 +896,188 @@ window.__TM_OPERATION_INFO_LAYOUT = Object.assign({
};
}
function createEmptyXDetailPage() {
return { items: [], usedHeight: 0 };
}
function fillXPageUnits(units, unitIndex, availableHeight) {
var pageUnits = [];
var remainingHeight = availableHeight;
function paginateXDetailPages(ctx, items, contentWidth) {
var bodyWidth = contentWidth - 32;
var maxHeight = X_CAPTURE_CONTENT_BOTTOM - X_CAPTURE_CONTENT_TOP;
var pages = [createEmptyXDetailPage()];
while (unitIndex < units.length) {
var budget = remainingHeight;
if (pageUnits.length) {
budget -= X_CAPTURE_UNIT_GAP;
}
if (budget <= 0) {
break;
}
for (var itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
var item = items[itemIndex];
var units = createXDetailUnits(ctx, item, bodyWidth);
var unitIndex = 0;
var continued = false;
var chunkInfo = buildXUnitChunk(units[unitIndex], budget);
if (!chunkInfo) {
break;
}
while (unitIndex < units.length) {
var page = pages[pages.length - 1];
var gapBeforeItem = page.items.length ? X_CAPTURE_ITEM_GAP : 0;
var header = measureXDetailHeader(ctx, item, contentWidth, continued);
var availableForStart = maxHeight - page.usedHeight - gapBeforeItem - header.height;
var preview = buildXUnitChunk(units[unitIndex], availableForStart);
if (pageUnits.length) {
remainingHeight -= X_CAPTURE_UNIT_GAP;
}
pageUnits.push(chunkInfo.chunk);
remainingHeight -= chunkInfo.height;
if (!preview) {
if (page.items.length) {
pages.push(createEmptyXDetailPage());
continue;
}
return null;
}
var pageItem = {
header: header,
units: []
};
if (gapBeforeItem) {
page.usedHeight += gapBeforeItem;
}
page.items.push(pageItem);
page.usedHeight += header.height;
while (unitIndex < units.length) {
var availableHeight = maxHeight - page.usedHeight;
var chunkInfo = buildXUnitChunk(units[unitIndex], availableHeight);
if (!chunkInfo) {
break;
}
pageItem.units.push(chunkInfo.chunk);
page.usedHeight += chunkInfo.height;
if (chunkInfo.consumed) {
unitIndex += 1;
} else if (chunkInfo.rest) {
units[unitIndex] = chunkInfo.rest;
}
if (unitIndex < units.length) {
page.usedHeight += X_CAPTURE_UNIT_GAP;
}
}
if (unitIndex < units.length) {
pages.push(createEmptyXDetailPage());
continued = true;
}
if (chunkInfo.consumed) {
unitIndex += 1;
} else if (chunkInfo.rest) {
units[unitIndex] = chunkInfo.rest;
}
}
return pages.filter(function(page) {
return page.items.length > 0;
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) {
@@ -1035,7 +1179,7 @@ window.__TM_OPERATION_INFO_LAYOUT = Object.assign({
});
}
async function buildXCoverPage(items, totalPages, pageIndex) {
async function buildXHeroPage(page, pageIndex, totalPages) {
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');
if (!ctx) return null;
@@ -1044,51 +1188,79 @@ window.__TM_OPERATION_INFO_LAYOUT = Object.assign({
canvas.height = X_CAPTURE_PAGE_HEIGHT;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawXPageChrome(ctx, pageIndex, totalPages, '運行情報・路線図');
drawXPageChrome(ctx, pageIndex, totalPages, 'X投稿向け画像');
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);
var summary = buildXCoverSummary(items);
ctx.font = "700 38px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
var summaryLines = wrapText(ctx, summary, X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X * 2 - 44).slice(0, 4);
var summaryHeight = Math.max(138, 32 + summaryLines.length * 46);
ctx.fillStyle = '#0f76a8';
ctx.fillRect(X_CAPTURE_SAFE_X, 186, X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X * 2, summaryHeight);
ctx.fillStyle = '#ffffff';
summaryLines.forEach(function(line, index) {
ctx.fillText(line, X_CAPTURE_SAFE_X + 24, 236 + index * 46);
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(X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X * 2 - 30);
mapCanvas = await buildMapImage(contentWidth - 32);
} catch (e) {
return null;
}
if (!mapCanvas) return null;
var mapCardY = 186 + summaryHeight + 34;
var mapX = X_CAPTURE_SAFE_X + 15;
var mapY = mapCardY + 16;
ctx.fillStyle = '#ffffff';
var mapTop = heroTop + hero.height + 24;
ctx.strokeStyle = '#c7dcea';
ctx.lineWidth = 2;
ctx.strokeRect(X_CAPTURE_SAFE_X, mapCardY, X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X * 2, mapCanvas.height + 32);
ctx.drawImage(mapCanvas, mapX, mapY, mapCanvas.width, mapCanvas.height);
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 latestUpdatedAt = getLatestUpdatedAt(items);
var infoY = mapCardY + mapCanvas.height + 72;
ctx.fillStyle = '#0f1720';
ctx.font = "700 28px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
ctx.fillText('現在表示中の路線図と運行情報', X_CAPTURE_SAFE_X, infoY);
if (latestUpdatedAt) {
ctx.fillStyle = '#4b5563';
ctx.font = "500 24px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
ctx.fillText(latestUpdatedAt, X_CAPTURE_SAFE_X, infoY + 40);
}
if (totalPages > 1) {
ctx.fillStyle = '#0099CB';
ctx.font = "800 30px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
ctx.fillText('詳細は次の画像へ →', X_CAPTURE_SAFE_X, infoY + 94);
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);
@@ -1104,21 +1276,50 @@ window.__TM_OPERATION_INFO_LAYOUT = Object.assign({
canvas.height = X_CAPTURE_PAGE_HEIGHT;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawXPageChrome(ctx, pageIndex, totalPages, '運行情報詳細');
drawXPageChrome(ctx, pageIndex, totalPages, '詳細情報');
var y = X_CAPTURE_CONTENT_TOP;
var width = X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X * 2;
page.items.forEach(function(pageItem, itemIndex) {
if (itemIndex > 0) {
y += X_CAPTURE_ITEM_GAP;
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;
}
drawXDetailHeaderBlock(ctx, pageItem.header, X_CAPTURE_SAFE_X, y, width);
y += pageItem.header.height;
pageItem.units.forEach(function(unit, unitIndex) {
drawXUnitBlock(ctx, unit, X_CAPTURE_SAFE_X, y, width);
y += unit.height;
if (unitIndex < pageItem.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;
}
});
});
@@ -1127,6 +1328,16 @@ window.__TM_OPERATION_INFO_LAYOUT = Object.assign({
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) {
@@ -1141,43 +1352,52 @@ window.__TM_OPERATION_INFO_LAYOUT = Object.assign({
return;
}
var detailPages = paginateXDetailPages(measureCtx, items, X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X * 2);
if (!detailPages || detailPages.length > 3) {
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 = 1 + detailPages.length;
var totalPages = pages.length;
var timestamp = strip(fileNameBase) || String(Date.now());
var cover = await buildXCoverPage(items, totalPages, 0);
if (!cover) {
postMessage({ error: true, reason: 'x-map' });
return;
}
var pages = [cover];
detailPages.forEach(function(page, index) {
var detailCanvas = buildXDetailPage(page, index + 1, totalPages);
if (detailCanvas) {
pages.push(detailCanvas);
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;
}
});
if (pages.length !== totalPages) {
postMessage({ error: true });
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 < pages.length; index += 1) {
for (var index = 0; index < renderedPages.length; index += 1) {
var rendered = renderedPages[index];
postMessage({
batchId: batchId,
batchIndex: index,
batchTotal: pages.length,
dataUrl: pages[index].toDataURL('image/png'),
fileName: index === 0
? 'operation-info-x-01-map-' + timestamp + '.png'
: 'operation-info-x-0' + String(index + 1) + '-detail-' + timestamp + '.png'
batchTotal: renderedPages.length,
dataUrl: rendered.canvas.toDataURL('image/png'),
fileName: 'operation-info-x-' + formatXPageFileIndex(index) + '-' + rendered.token + '-' + rendered.kind + '-' + timestamp + '.png'
});
}
} catch (e) {
@@ -1780,6 +2000,27 @@ window.__TM_OPERATION_INFO_LAYOUT = Object.assign({
});
};
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);
}
@@ -0,0 +1,40 @@
# 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追加を再検討する。
@@ -0,0 +1,232 @@
# アプリ向けVoicepeak公開音声生成エンドポイントの追加依頼
## 概要
JR四国非公式アプリでは、駅の発車標データなどをもとに「りっかちゃん」の案内原稿を生成し、Voicepeak APIから取得した音声を再生しています。
現在は、利用者がアプリの設定画面で以下を入力する構成です。
- Voicepeak APIのベースURL
- Bearerトークン
- 話者名
機能が実用段階に入ってきたため、一般利用者によるAPI設定を廃止し、アプリから直接利用できる公開エンドポイントを用意したいと考えています。
共通BearerトークンをアプリやEAS Updateへ組み込むと、アプリバンドルから抽出できてしまいます。そのため、既存の管理用APIは認証付きのまま維持し、制限されたアプリ専用エンドポイントを認証なしで追加する方針です。
## 希望する構成
同じVoicepeakサーバー内で、用途別に2つのエンドポイントを提供します。
```text
JR四国非公式アプリ
│ 認証なし
POST /v1/public/speech
│ りっかちゃん固定・入力制限・レート制限・キャッシュ
Voicepeak音声生成処理
管理・開発ツール
│ Bearer認証
POST /v1/speech
│ 既存機能を維持
Voicepeak音声生成処理
```
サーバーを2台に分ける意図ではありません。既存サーバー内に公開用の入口を追加し、管理用APIと権限・機能を分離する想定です。
## エンドポイント案
### アプリ向け
```http
POST /v1/public/speech
Content-Type: application/json
```
認証は要求しません。
リクエスト例:
```json
{
"text": "次の、2番線に参ります列車は、12時34分発、特急しおかぜ、岡山行きです。",
"format": "mp3"
}
```
レスポンス:
- 成功時は音声バイナリを返す
- `format: "mp3"`の場合は`Content-Type: audio/mpeg`
- `format: "wav"`の場合は`Content-Type: audio/wav`
話者はリクエストから指定させず、サーバー側で「りっかちゃん」に固定することを希望します。
後方互換性の都合で`speaker`を受け取る場合も、公開エンドポイントでは値を無視するか、許可されたりっかちゃんの識別子以外を拒否してください。
### 管理・開発向け
```http
POST /v1/speech
Authorization: Bearer <secret>
Content-Type: application/json
```
こちらは現在の仕様とBearer認証を維持します。話者変更など、公開APIに不要な機能は管理用APIだけで提供します。
## 公開エンドポイントに必要な制限
### 必須
- 原稿は空文字を拒否する
- 原稿は最大140文字とする
- リクエスト本文全体のサイズ上限を設ける
- 利用可能な形式を`mp3``wav`に限定する
- 話者をりっかちゃんに固定する
- Voicepeakプロセスへの同時実行数を制限する
- 上限を超えた生成要求はサーバー内キューで順番に処理する
- IPなどを利用したレート制限を設ける
- タイムアウトを設定する
- 管理用APIや管理画面は公開APIから到達できないようにする
- 制限値は環境変数などで変更可能にする
### 推奨
- 同一原稿の生成結果をキャッシュする
- キャッシュキーに正規化後の原稿、形式、話者、音声モデルのバージョンを含める
- キャッシュの最大容量と有効期限を設定する
- リクエスト数、成功数、失敗数、生成時間、キュー待ち時間を計測する
- 公開APIだけを即時停止できる緊急停止スイッチを設ける
- 異常なアクセス元を一時的に遮断できるようにする
## 文字数の数え方
アプリ側では、半角カタカナをNFKC正規化して全角へ変換したあと、135文字以内になるように原稿を分割しています。140文字ぎりぎりではなく、5文字分の余裕を設けています。
サーバー側で以下を明文化してもらえると助かります。
- 正規化前と正規化後のどちらで140文字を判定するか
- Unicodeコードポイント、UTF-16コード単位、UTF-8バイト数のどれを「文字数」とするか
- 改行や空白を文字数へ含めるか
アプリとの不一致を防ぐため、サーバー側でもNFKC正規化後の文字列を判定対象にする案を希望します。
## 現在確認している並列生成上の懸念
長い案内は、アプリ側で複数の135文字以内の原稿へ分割します。現在のアプリ実装では、再生前にすべての音声を取得するため、分割されたリクエストを同時送信する場合があります。
事前案内だけ再生されない現象があり、Voicepeakサーバーが複数の音声生成を同時に受けた際、その一部を拒否している可能性を調査しています。
公開エンドポイントでは、複数リクエストを受けてもVoicepeakプロセスへ安全に直列化できるキューを希望します。
確認したい項目:
- 現在のサーバーが同時生成を何件まで処理できるか
- 同時実行上限を超えた場合の現在の挙動
- 待機させる場合の最大キュー長
- キュー満杯時に返すHTTPステータス
- クライアント側で推奨される再試行方法
アプリ側も必要に応じて、分割音声を1件ずつ順番に取得する方式へ変更できます。
## エラーレスポンス案
エラー時は、アプリのデバッグログで原因を識別できるJSONレスポンスを希望します。
```json
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many speech generation requests",
"retryAfterSeconds": 10,
"requestId": "..."
}
}
```
ステータスコード案:
| ステータス | 用途 |
|---|---|
| `400` | JSON形式、必須項目、形式指定などが不正 |
| `413` | リクエスト本文が大きすぎる |
| `422` | 原稿が空、または140文字を超えている |
| `429` | レート制限、または一時的な生成上限 |
| `500` | サーバー内部エラー |
| `503` | Voicepeak停止中、キュー満杯、メンテナンス中 |
`429`または`503`の場合は、可能であれば`Retry-After`ヘッダーも返してください。
## ログとプライバシー
原稿には駅名、列車名、時刻、行き先、運行状況などが含まれます。通常は個人情報を含みませんが、自由入力値が混入する可能性を完全には排除できません。
サーバーログでは以下を推奨します。
- Authorizationヘッダーや管理用シークレットを絶対に記録しない
- 原稿本文を保存する場合は保持期間を決める
- 通常の計測は原稿ハッシュ、文字数、生成時間、結果だけでも行えるようにする
- リクエストごとに追跡用`requestId`を発行する
アプリ側にはVoicepeakデバッグログ機能を追加済みです。
- 送信原稿
- 文字数
- 分割番号
- HTTPステータス
- 処理時間
- 応答サイズ
- エラー内容
- OSとRuntime Version
を端末内へ最大7日間保存できます。APIトークンは記録していません。
## アプリ側の移行予定
公開エンドポイントの準備完了後、JR四国非公式アプリ側で以下を変更します。
1. 音声生成先を`/v1/public/speech`へ変更
2. `Authorization`ヘッダーを送信しない
3. Voicepeak API URL入力欄を削除
4. APIトークン入力欄を削除
5. 話者名入力欄を削除
6. 既存端末に保存されたAPIトークンを削除
7. 音声案内の有効・無効設定は維持
8. Voicepeakデバッグログ機能は維持
9. 必要に応じて分割音声の取得を直列化
公開エンドポイントのパスが確定するまでは、現在の認証付き`/v1/speech`を継続利用します。
## 受け入れ条件
- 認証なしで`POST /v1/public/speech`からりっかちゃんの音声を取得できる
- 管理用`POST /v1/speech`は引き続きBearer認証が必要
- 公開APIから話者を変更できない
- 140文字以内の日本語原稿をMP3とWAVで生成できる
- 141文字以上の原稿が明確なエラーで拒否される
- 不正な形式指定が拒否される
- 複数リクエストが到着してもVoicepeakプロセスが競合しない
- レート制限時にクライアントが判別可能なエラーを返す
- 管理用シークレットがレスポンスやログへ露出しない
- 公開APIだけを停止できる
## Voicepeak開発側へ確認したい事項
1. 公開エンドポイントを同一サーバーへ追加できるか
2. `/v1/public/speech`というパスで問題ないか
3. 公開APIで利用する正式な話者識別子
4. 140文字制限の具体的な判定方法
5. MP3とWAVの両方を公開APIで許可できるか
6. 現在の同時生成制限と、サーバーキューの実装可否
7. 適切なレート制限値
8. キャッシュの実装可否
9. エラー形式と`requestId`の付与可否
10. 公開予定日と、アプリ側が切り替えてよいタイミング
+3
View File
@@ -72,6 +72,9 @@ export type CustomTrainData = {
isThrough: boolean;
platformNum: string | null;
se?: string;
isOrigin?: boolean;
arrivalTime?: string;
departureTime?: string;
};
export type StationProps = {
+176
View File
@@ -0,0 +1,176 @@
import {
hasNotificationSound,
saveNotificationSound,
scheduleLocationAnnouncements,
type LocationAnnouncement,
} from "expo-live-activity";
import {
hasVoicepeakConfiguration,
loadVoicepeakSettings,
requestVoicepeakSpeechBytes,
} from "@/lib/voicepeak";
import { encodeBase64 } from "@/lib/voicepeakAudioSource";
import * as Notifications from "expo-notifications";
export type BackgroundRikkaTriggerSource =
| "deviceLocation"
| "trainPosition";
export const DEFAULT_BACKGROUND_RIKKA_TRIGGER_SOURCE: BackgroundRikkaTriggerSource =
"deviceLocation";
export type BackgroundRikkaStation = {
identifier: string;
stationName: string;
latitude: number;
longitude: number;
};
export type BackgroundRikkaPreparationResult = {
scheduled: number;
failedStations: string[];
};
const MAX_LOCATION_ANNOUNCEMENTS = 20;
const PREPARE_CONCURRENCY = 3;
const DEFAULT_APPROACH_RADIUS_METERS = 800;
const notificationSoundFileName = (stationName: string) => {
let hash = 2166136261;
for (let index = 0; index < stationName.length; index++) {
hash ^= stationName.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return `rikka-next-${(hash >>> 0).toString(36)}.wav`;
};
const ensureNotificationSound = async (
stationName: string,
signal?: AbortSignal
) => {
const settings = await loadVoicepeakSettings();
if (!hasVoicepeakConfiguration(settings)) {
throw new Error("Voicepeak configuration is required");
}
const soundFileName = notificationSoundFileName(stationName);
if (!hasNotificationSound(soundFileName)) {
const audioBytes = await requestVoicepeakSpeechBytes({
text: `次は、${stationName}です。`,
settings,
signal,
format: "wav",
});
await saveNotificationSound(soundFileName, encodeBase64(audioBytes));
}
return soundFileName;
};
export const sendTrainPositionRikkaAnnouncement = async ({
stationName,
trainId,
signal,
}: {
stationName: string;
trainId: string;
signal?: AbortSignal;
}) => {
const soundFileName = await ensureNotificationSound(stationName, signal);
if (signal?.aborted) return;
await Notifications.scheduleNotificationAsync({
content: {
title: "列車追従・りっかちゃん",
body: `次は、${stationName}です。`,
sound: soundFileName,
data: {
type: "train-follow-announcement",
stationName,
trainId,
source: "trainPosition",
},
},
trigger: null,
});
};
export const prepareBackgroundRikkaAnnouncements = async ({
trackingId,
stations,
signal,
}: {
trackingId: string;
stations: BackgroundRikkaStation[];
signal?: AbortSignal;
}): Promise<BackgroundRikkaPreparationResult> => {
const deduplicated = stations
.filter(
(station) =>
station.stationName &&
Number.isFinite(station.latitude) &&
Number.isFinite(station.longitude)
)
.filter(
(station, index, array) =>
array.findIndex((candidate) => candidate.identifier === station.identifier) ===
index
)
.slice(0, MAX_LOCATION_ANNOUNCEMENTS);
const prepared: Array<LocationAnnouncement | null> = new Array(
deduplicated.length
).fill(null);
const failedStations: string[] = [];
let cursor = 0;
const prepareNext = async () => {
while (cursor < deduplicated.length) {
const index = cursor++;
const station = deduplicated[index];
if (signal?.aborted) return;
const soundFileName = notificationSoundFileName(station.stationName);
try {
await ensureNotificationSound(station.stationName, signal);
prepared[index] = {
identifier: station.identifier,
stationName: station.stationName,
latitude: station.latitude,
longitude: station.longitude,
radiusMeters: DEFAULT_APPROACH_RADIUS_METERS,
soundFileName,
};
} catch (error) {
if (!signal?.aborted) {
failedStations.push(station.stationName);
console.warn(
`[BackgroundRikka] Failed to prepare ${station.stationName}`,
error
);
}
}
}
};
await Promise.all(
Array.from(
{ length: Math.min(PREPARE_CONCURRENCY, deduplicated.length) },
prepareNext
)
);
if (signal?.aborted) {
return { scheduled: 0, failedStations };
}
const announcements = prepared.filter(
(announcement): announcement is LocationAnnouncement => announcement != null
);
const scheduled =
announcements.length > 0
? await scheduleLocationAnnouncements(trackingId, announcements)
: 0;
return { scheduled, failedStations };
};
+30
View File
@@ -0,0 +1,30 @@
import { STORAGE_KEYS } from "@/constants";
import { AS } from "@/storageControl";
const LEGACY_VOICEPEAK_STORAGE_KEYS = [
STORAGE_KEYS.VOICEPEAK_BASE_URL,
STORAGE_KEYS.VOICEPEAK_API_TOKEN,
STORAGE_KEYS.VOICEPEAK_SPEAKER,
"voicepeakSpeed",
"voicepeakPitch",
"voicepeakPause",
"voicepeakVolume",
"voicepeakHightension",
"voicepeakNarration",
"voicepeakParameters",
"voicepeakParams",
"voicepeakEmotions",
"voicepeakEmotion",
] as const;
/**
* 公開APIへの移行後は、端末へ管理用APIの設定を保持しない。
* removeItemは対象が存在しない場合も安全なため、起動ごとに実行できる。
*/
export const migrateLegacyVoicepeakSettings = async () => {
await Promise.all(
LEGACY_VOICEPEAK_STORAGE_KEYS.map((key) =>
AS.removeItem(key).catch(() => undefined)
)
);
};
+40
View File
@@ -0,0 +1,40 @@
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の最終更新時刻を基準に、次の5分更新後10〜50秒の間へ取得を分散する。
* 更新予定時刻を過ぎても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));
}
+31 -6
View File
@@ -103,6 +103,15 @@ 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: "",
@@ -110,9 +119,10 @@ export const getTime: getTimeProps = (stationDiagram, station) => {
train: trainNum,
platformNum: null,
se: undefined,
arrivalTime: undefined,
departureTime: undefined,
};
stationDiagram[trainNum].split("#").forEach((data) => {
const [stationName, type, time, platformNum] = data.split(",");
diagramEntries.forEach(({ stationName, type, time, platformNum }) => {
if (!type) return;
if (type.match("着")) {
trainData.lastStation = stationName;
@@ -122,21 +132,36 @@ export const getTime: getTimeProps = (stationDiagram, station) => {
trainData.se = type;
if (type.match("発")) {
trainData.time = time;
} else if (type.match("通")) {
trainData.departureTime = time;
}
if (type.match("着")) {
trainData.arrivalTime = time;
if (!trainData.departureTime) {
trainData.time = time;
}
}
if (type.match("通")) {
trainData.time = time;
trainData.isThrough = true;
} else if (type.match("着")) {
trainData.time = time;
}
}
});
return {
train: trainNum,
time: trainData.time,
time:
trainData.departureTime ||
trainData.arrivalTime ||
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);
+807
View File
@@ -0,0 +1,807 @@
import dayjs from "dayjs";
import { STORAGE_KEYS } from "@/constants";
import type { CustomTrainData, StationProps, eachTrainDiagramType } from "@/lib/CommonTypes";
import { getTrainType } from "@/lib/getTrainType";
import { AS } from "@/storageControl";
import {
createVoicepeakAudioSource,
type PreparedVoicepeakAudio,
} from "@/lib/voicepeakAudioSource";
import {
completeVoicepeakDebugLog,
createVoicepeakDebugLog,
} from "@/lib/voicepeakDebugLog";
export const VOICEPEAK_PUBLIC_BASE_URL = "https://voicepeak-api.haruk.in";
export const VOICEPEAK_PUBLIC_SPEECH_PATH = "/v1/public/speech";
export const VOICEPEAK_ADVANCE_ATTEMPT_SECONDS = 150;
export const VOICEPEAK_DEPARTURE_ATTEMPT_SECONDS = 30;
export const VOICEPEAK_PASSING_GRACE_SECONDS = 15;
export const VOICEPEAK_SPEECH_TEXT_LIMIT = 135;
export const VOICEPEAK_REQUEST_TIMEOUT_MILLISECONDS = 90_000;
export type VoicepeakSettings = {
enabled: boolean;
};
export type VoicepeakAnnouncementStage = "advance" | "departure" | "passing";
export type VoicepeakSpeechOptions = {
force?: boolean;
signal?: AbortSignal;
};
type VoicepeakSpeechRequest = VoicepeakSpeechOptions & {
text: string;
settings: VoicepeakSettings;
debug?: {
batchId?: string;
chunkIndex?: number;
totalChunks?: number;
};
};
type VoicepeakErrorBody = {
error?: {
code?: string;
message?: string;
retryAfterSeconds?: number;
requestId?: string;
};
};
const readStoredString = async (key: string, fallback = "") => {
try {
const value = await AS.getItem(key);
return typeof value === "string" ? value : fallback;
} catch {
return fallback;
}
};
const replaceFullWidthSpacesWithSpeechPauses = (text: string) =>
text
.replace(/ +/g, "、")
.replace(/([、。!?!?])、+/g, "$1")
.replace(/、+([、。!?!?])/g, "$1")
.replace(/、{2,}/g, "、");
export const normalizeVoicepeakSpeechText = (text: string) =>
replaceFullWidthSpacesWithSpeechPauses(text).normalize("NFKC").trim();
export const countVoicepeakSpeechCodePoints = (text: string) =>
Array.from(text).length;
export const splitVoicepeakSpeechText = (
text: string,
maxLength = VOICEPEAK_SPEECH_TEXT_LIMIT
) => {
const normalizedText = normalizeVoicepeakSpeechText(text).trim();
if (!normalizedText) return [];
const safeMaxLength = Math.max(1, Math.min(maxLength, 140));
const remainingCharacters = Array.from(normalizedText);
const chunks: string[] = [];
const boundaryPriorities = [
new Set(["。", "", "", "\n"]),
new Set(["、", "", ","]),
new Set([" "]),
];
while (remainingCharacters.length > safeMaxLength) {
const searchableCharacterCount = safeMaxLength;
const searchableCharacters = remainingCharacters.slice(
0,
searchableCharacterCount
);
let splitIndex = -1;
for (const boundaries of boundaryPriorities) {
for (let index = searchableCharacters.length - 1; index >= 0; index--) {
if (boundaries.has(searchableCharacters[index])) {
splitIndex = index + 1;
break;
}
}
if (splitIndex > 0) break;
}
if (splitIndex <= 0) {
splitIndex = Math.max(1, searchableCharacterCount);
}
const chunk = remainingCharacters.splice(0, splitIndex).join("").trim();
if (chunk) chunks.push(chunk);
}
const finalChunk = remainingCharacters.join("").trim();
if (finalChunk) chunks.push(finalChunk);
return chunks;
};
export const loadVoicepeakSettings = async (): Promise<VoicepeakSettings> => ({
enabled:
(await readStoredString(STORAGE_KEYS.VOICEPEAK_ENABLED, "false")) === "true",
});
export const hasVoicepeakConfiguration = (settings: VoicepeakSettings) =>
settings.enabled;
export const buildVoicepeakAnnouncementKey = (
station: StationProps,
train: eachTrainDiagramType,
stage: VoicepeakAnnouncementStage
) => {
const [hourText] = train.time.split(":");
const hour = Number.parseInt(hourText, 10);
const serviceDate = dayjs()
.subtract(Number.isNaN(hour) ? 0 : hour < 4 ? 1 : 0, "day")
.format("YYYY-MM-DD");
return [
serviceDate,
stage,
station.StationNumber || station.Station_JP,
train.train,
train.time,
train.lastStation,
].join(":");
};
const getDepartureTiming = (timeText: string, delayMinutes = 0) => {
const [hourText, minuteText] = timeText.split(":");
const hour = Number.parseInt(hourText, 10);
const minute = Number.parseInt(minuteText, 10);
if (Number.isNaN(hour) || Number.isNaN(minute)) return null;
const now = dayjs();
const departureTime = now
.set("hour", hour < 4 ? hour + 24 : hour)
.set("minute", minute + delayMinutes)
.set("second", 0)
.set("millisecond", 0);
return {
now,
departureTime,
diffSeconds: departureTime.diff(now, "second"),
diffMilliseconds: departureTime.diff(now),
};
};
const getTrainNumberSuffix = (
trainId: string,
trainNumDistance: string | null | undefined
) => {
if (
trainNumDistance === undefined ||
trainNumDistance === null ||
trainNumDistance === "" ||
Number.isNaN(Number.parseInt(trainNumDistance, 10))
) {
return "";
}
const trainNumber =
Number.parseInt(trainId.replace(/\D/g, ""), 10) -
Number.parseInt(trainNumDistance, 10);
return Number.isNaN(trainNumber) ? "" : `${trainNumber}`;
};
const getEffectiveTrainDestination = (
train: eachTrainDiagramType,
currentTrainData: CustomTrainData
) => currentTrainData.to_data?.trim() || train.lastStation.trim();
const isTrainTerminatingAtStation = (
station: StationProps,
train: eachTrainDiagramType,
currentTrainData: CustomTrainData
) => {
const destination = getEffectiveTrainDestination(train, currentTrainData);
if (destination === "当駅止" || destination === "当駅止まり") return true;
const destinationStation = destination.replace(/(?:行き?|止まり)$/, "");
return destinationStation === station.Station_JP;
};
const buildTrainDestination = (
station: StationProps,
train: eachTrainDiagramType,
currentTrainData: CustomTrainData
) => {
const destination = getEffectiveTrainDestination(train, currentTrainData);
if (isTrainTerminatingAtStation(station, train, currentTrainData)) {
return `${station.Station_JP}止まり`;
}
return /行き?$/.test(destination) ? destination : `${destination}行き`;
};
const buildTrainLabel = (
trainTypeName: string,
trainName: string,
trainNumberSuffix: string
) => [trainTypeName, trainName, trainNumberSuffix].filter(Boolean).join("、");
export const getVoicepeakAnnouncementStage = ({
station,
train,
currentTrainData,
delayMinutes,
}: {
station: StationProps;
train: eachTrainDiagramType;
currentTrainData: CustomTrainData;
delayMinutes?: number;
}): VoicepeakAnnouncementStage | null => {
const trainType = getTrainType({ type: currentTrainData.type, id: train.train });
if (!train.isThrough && trainType.data === "notService") return null;
const passingDelayMinutes =
train.isThrough &&
typeof delayMinutes === "number" &&
Number.isFinite(delayMinutes) &&
delayMinutes > 0
? delayMinutes
: 0;
const timing = getDepartureTiming(train.time, passingDelayMinutes);
if (!timing) return null;
if (train.isThrough) {
if (train.se?.includes("休")) return null;
return timing.diffMilliseconds <= 0 &&
timing.diffMilliseconds > -VOICEPEAK_PASSING_GRACE_SECONDS * 1000
? "passing"
: null;
}
if (
timing.diffSeconds > 0 &&
timing.diffSeconds <= VOICEPEAK_DEPARTURE_ATTEMPT_SECONDS
) {
return "departure";
}
if (
timing.diffSeconds > 0 &&
timing.diffSeconds < VOICEPEAK_ADVANCE_ATTEMPT_SECONDS
) {
return "advance";
}
return null;
};
export const buildVoicepeakAnnouncementText = ({
station,
train,
currentTrainData,
stage,
delayMinutes,
isOrigin = false,
isStoppedAtStation = false,
advanceTimeBasis = "departure",
}: {
station: StationProps;
train: eachTrainDiagramType;
currentTrainData: CustomTrainData;
stage: VoicepeakAnnouncementStage;
delayMinutes?: number;
isOrigin?: boolean;
isStoppedAtStation?: boolean;
advanceTimeBasis?: "arrival" | "departure";
}) => {
const trainType = getTrainType({ type: currentTrainData.type, id: train.train });
const trainNumberSuffix = getTrainNumberSuffix(
train.train,
currentTrainData.train_num_distance
);
const destination = buildTrainDestination(station, train, currentTrainData);
const trainLabel = buildTrainLabel(
trainType.name,
currentTrainData.train_name,
trainNumberSuffix
);
const isTerminating = isTrainTerminatingAtStation(
station,
train,
currentTrainData
);
const hasArrivalTime =
!!train.arrivalTime || !!train.se?.includes("着");
const hasDepartureTime =
!!train.departureTime || !!train.se?.includes("発");
const isArrivalTimeOnly = hasArrivalTime && !hasDepartureTime;
const isArrivalBasedAdvance =
stage === "advance" &&
(isArrivalTimeOnly || advanceTimeBasis === "arrival");
const advanceLead = isStoppedAtStation
? train.platformNum?.trim()
? [`只今、${train.platformNum.trim()}番線に停車中の列車は`]
: ["只今、当駅に停車中の列車は"]
: train.platformNum?.trim()
? [`次の、${train.platformNum.trim()}番線に参ります列車は`]
: ["次の列車は"];
if (stage === "passing") {
const passingPlatform = train.platformNum?.trim()
? `${train.platformNum.trim()}番乗り場を`
: "ホームを";
return [
"間もなく",
passingPlatform,
"列車が通過します。",
"危険ですので",
"黄色い線、点字ブロックまでお下がりください。",
].join("、");
}
if (stage === "advance") {
const trainInfoText = currentTrainData.train_info?.trim();
const [hourText, minuteText] = train.time.split(":");
const departureTimeText = [
`${Number.parseInt(hourText || "0", 10)}`,
`${Number.parseInt(minuteText || "0", 10)}${
isArrivalBasedAdvance ? "着" : "発"
}`,
];
const parsedDelay =
typeof delayMinutes === "number" && Number.isFinite(delayMinutes) && delayMinutes > 0
? `${delayMinutes}分遅れで`
: "定刻で";
if (isTerminating) {
const terminatingDestination = `${station.Station_JP}行きです。`;
return [
...advanceLead,
"当駅止まりとなります、",
trainLabel,
terminatingDestination,
"現在",
parsedDelay,
"運転しております。",
"まもなく到着します。",
]
.filter(Boolean)
.join("、");
}
if (isOrigin && !isArrivalBasedAdvance) {
return [
...advanceLead,
"当駅始発となります、",
...departureTimeText,
trainLabel,
`${destination}です。`,
trainInfoText,
"まもなく発車します。",
]
.filter(Boolean)
.join("、");
}
const prefix = [
...advanceLead,
...departureTimeText,
trainLabel,
`${destination}です。`,
"この列車は",
"現在",
parsedDelay,
"運転しております。",
].join("、");
return [
prefix,
trainInfoText,
isArrivalBasedAdvance
? "まもなく到着します。ご注意ください。"
: "まもなく発車します。",
]
.filter(Boolean)
.join("、");
}
const platformText = train.platformNum?.trim()
? `${train.platformNum.trim()}番線${
isTerminating || isArrivalTimeOnly ? "に" : "より"
}`
: "";
if (isTerminating) {
return [
"間もなく",
platformText,
`当駅止まりの${trainLabel || "列車"}`,
"到着します。",
"ご注意ください。",
]
.filter(Boolean)
.join("、");
}
if (isArrivalTimeOnly) {
return [
"間もなく",
platformText,
trainLabel,
`${destination}の列車が`,
"到着します。",
"ご注意ください。",
]
.filter(Boolean)
.join("、");
}
const pieces = [
"間もなく",
platformText,
trainLabel,
destination,
"が",
"発車します。",
"ご注意ください。",
].filter(Boolean);
return pieces.join("、");
};
export class VoicepeakRequestError extends Error {
constructor(
message: string,
readonly status: number,
readonly code: string,
readonly retryAfterSeconds?: number,
readonly requestId?: string,
readonly retryable = false
) {
super(message);
this.name = "VoicepeakRequestError";
}
}
const parseOptionalNumber = (value: string | null) => {
if (value === null || value.trim() === "") return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
};
const getRetryAfterSeconds = (
response: Response,
body: VoicepeakErrorBody | null
) => {
const retryAfter = response.headers.get("Retry-After");
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds) && seconds >= 0) return seconds;
const retryAt = Date.parse(retryAfter);
if (Number.isFinite(retryAt)) {
return Math.max(0, Math.ceil((retryAt - Date.now()) / 1000));
}
}
const bodySeconds = body?.error?.retryAfterSeconds;
return typeof bodySeconds === "number" && Number.isFinite(bodySeconds)
? Math.max(0, bodySeconds)
: undefined;
};
const waitForRetry = (milliseconds: number, signal?: AbortSignal) =>
new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(new VoicepeakRequestError("Voicepeak request aborted", 0, "ABORTED"));
return;
}
const timeout = setTimeout(() => {
signal?.removeEventListener("abort", handleAbort);
resolve();
}, milliseconds);
const handleAbort = () => {
clearTimeout(timeout);
reject(new VoicepeakRequestError("Voicepeak request aborted", 0, "ABORTED"));
};
signal?.addEventListener("abort", handleAbort, { once: true });
});
const requestVoicepeakSpeechBytesOnce = async ({
text,
signal,
debug,
format,
attemptNumber,
force,
}: {
text: string;
signal?: AbortSignal;
debug?: VoicepeakSpeechRequest["debug"];
format: "mp3" | "wav";
attemptNumber: number;
force: boolean;
}) => {
const startedAt = Date.now();
const logId = await createVoicepeakDebugLog({
text,
format,
baseUrl: VOICEPEAK_PUBLIC_BASE_URL,
batchId: debug?.batchId,
chunkIndex: debug?.chunkIndex,
totalChunks: debug?.totalChunks,
attemptNumber,
forceRequested: force,
}).catch((error) => {
console.warn("Failed to create Voicepeak debug log", error);
return undefined;
});
let resultLogged = false;
const completeLog = async (
result: Parameters<typeof completeVoicepeakDebugLog>[1]
) => {
if (!logId) return;
await completeVoicepeakDebugLog(logId, result).catch((error) => {
console.warn("Failed to update Voicepeak debug log", error);
});
resultLogged = true;
};
const controller = new AbortController();
let timedOut = false;
const abortRequest = () => controller.abort();
if (signal?.aborted) controller.abort();
signal?.addEventListener("abort", abortRequest, { once: true });
const timeout = setTimeout(() => {
timedOut = true;
controller.abort();
}, VOICEPEAK_REQUEST_TIMEOUT_MILLISECONDS);
try {
const response = await fetch(
`${VOICEPEAK_PUBLIC_BASE_URL}${VOICEPEAK_PUBLIC_SPEECH_PATH}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
text,
format,
...(force ? { force: true } : {}),
}),
signal: controller.signal,
}
);
const headerRequestId = response.headers.get("X-Request-ID") || undefined;
const cacheStatus = response.headers.get("X-Voicepeak-Cache") || undefined;
const queueWaitMilliseconds = parseOptionalNumber(
response.headers.get("X-Voicepeak-Queue-Wait-Ms")
);
const queueDepth = parseOptionalNumber(
response.headers.get("X-Voicepeak-Queue-Depth")
);
if (!response.ok) {
const rawBody = await response.text().catch(() => "");
let body: VoicepeakErrorBody | null = null;
try {
body = rawBody ? (JSON.parse(rawBody) as VoicepeakErrorBody) : null;
} catch {
body = null;
}
const code = body?.error?.code || "UNKNOWN";
const requestId = body?.error?.requestId || headerRequestId;
const retryAfterSeconds = getRetryAfterSeconds(response, body);
const message =
body?.error?.message ||
rawBody ||
`Voicepeak request failed with status ${response.status}`;
const retryable =
response.status === 429 ||
response.status === 503 ||
response.status === 500;
const requestError = new VoicepeakRequestError(
message,
response.status,
code,
retryAfterSeconds,
requestId,
retryable
);
await completeLog({
status: "error",
httpStatus: response.status,
errorCode: code,
requestId,
cacheStatus,
queueWaitMilliseconds,
queueDepth,
durationMilliseconds: Date.now() - startedAt,
error: requestError.message,
});
throw requestError;
}
const bypassWarning =
force && cacheStatus !== "BYPASS"
? `force=true requested but X-Voicepeak-Cache was ${
cacheStatus || "missing"
}`
: undefined;
if (bypassWarning) {
console.warn(bypassWarning);
}
const expectedContentType = format === "wav" ? "audio/wav" : "audio/mpeg";
const contentType = response.headers.get("Content-Type") || "";
if (!contentType.toLowerCase().startsWith(expectedContentType)) {
throw new VoicepeakRequestError(
"Unexpected audio content type: " + (contentType || "missing"),
response.status,
"INVALID_CONTENT_TYPE",
undefined,
headerRequestId
);
}
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength === 0) {
throw new VoicepeakRequestError(
"Voicepeak returned empty audio",
response.status,
"EMPTY_AUDIO",
undefined,
headerRequestId,
true
);
}
await completeLog({
status: "success",
httpStatus: response.status,
requestId: headerRequestId,
cacheStatus,
queueWaitMilliseconds,
queueDepth,
durationMilliseconds: Date.now() - startedAt,
responseBytes: bytes.byteLength,
warning: bypassWarning,
});
return bytes;
} catch (error) {
const requestError =
error instanceof VoicepeakRequestError
? error
: new VoicepeakRequestError(
signal?.aborted
? "Voicepeak request aborted"
: timedOut
? "Voicepeak request timed out"
: error instanceof Error
? error.message
: String(error),
0,
signal?.aborted ? "ABORTED" : timedOut ? "TIMEOUT" : "NETWORK_ERROR",
undefined,
undefined,
!signal?.aborted
);
if (!resultLogged) {
await completeLog({
status: "error",
httpStatus: requestError.status || undefined,
errorCode: requestError.code,
requestId: requestError.requestId,
durationMilliseconds: Date.now() - startedAt,
error: requestError.message,
});
}
throw requestError;
} finally {
clearTimeout(timeout);
signal?.removeEventListener("abort", abortRequest);
}
};
export const requestVoicepeakSpeechBytes = async ({
text: rawText,
settings: _settings,
signal,
debug,
format = "mp3",
force = false,
}: VoicepeakSpeechRequest & {
format?: "mp3" | "wav";
}): Promise<Uint8Array> => {
const text = normalizeVoicepeakSpeechText(rawText);
const codePointCount = countVoicepeakSpeechCodePoints(text);
if (codePointCount === 0 || codePointCount > VOICEPEAK_SPEECH_TEXT_LIMIT) {
throw new VoicepeakRequestError(
`Invalid speech text length: ${codePointCount}`,
0,
"INVALID_TEXT"
);
}
const maximumRetryCount = 3;
for (let attemptNumber = 1; ; attemptNumber++) {
try {
return await requestVoicepeakSpeechBytesOnce({
text,
signal,
debug,
format,
attemptNumber,
force,
});
} catch (error) {
const requestError =
error instanceof VoicepeakRequestError
? error
: new VoicepeakRequestError(String(error), 0, "UNKNOWN");
const retryCount = attemptNumber;
if (
signal?.aborted ||
!requestError.retryable ||
retryCount > maximumRetryCount
) {
throw requestError;
}
const backoffMilliseconds =
requestError.retryAfterSeconds !== undefined
? requestError.retryAfterSeconds * 1000
: 2 ** retryCount * 1000 + Math.floor(Math.random() * 501);
await waitForRetry(backoffMilliseconds, signal);
}
}
};
export const requestVoicepeakSpeech = async (
request: VoicepeakSpeechRequest
): Promise<PreparedVoicepeakAudio> => {
const bytes = await requestVoicepeakSpeechBytes({
...request,
format: "mp3",
});
return createVoicepeakAudioSource(bytes, "mp3");
};
export const requestVoicepeakSpeeches = async (
request: VoicepeakSpeechRequest
): Promise<PreparedVoicepeakAudio[]> => {
const chunks = splitVoicepeakSpeechText(request.text)
.map(normalizeVoicepeakSpeechText)
.filter(
(text) =>
countVoicepeakSpeechCodePoints(text) > 0 &&
countVoicepeakSpeechCodePoints(text) <= VOICEPEAK_SPEECH_TEXT_LIMIT
);
const batchId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const audioBytes: Uint8Array[] = [];
for (let index = 0; index < chunks.length; index++) {
audioBytes.push(
await requestVoicepeakSpeechBytes({
...request,
text: chunks[index],
format: "mp3",
debug: {
batchId,
chunkIndex: index + 1,
totalChunks: chunks.length,
},
})
);
}
return Promise.all(
audioBytes.map((bytes) => createVoicepeakAudioSource(bytes, "mp3"))
);
};
+150
View File
@@ -0,0 +1,150 @@
import type { AudioSource } from "expo-audio";
import { File, Paths } from "expo-file-system";
import { Platform } from "react-native";
export type PreparedVoicepeakAudio =
| {
kind: "expo-audio";
source: AudioSource;
cleanup?: () => void;
}
| {
kind: "native-webview";
html: string;
};
const BASE64_CHARS =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
export const encodeBase64 = (bytes: Uint8Array) => {
let encoded = "";
for (let index = 0; index < bytes.length; index += 3) {
const byte1 = bytes[index] ?? 0;
const byte2 = bytes[index + 1] ?? 0;
const byte3 = bytes[index + 2] ?? 0;
const combined = (byte1 << 16) | (byte2 << 8) | byte3;
encoded += BASE64_CHARS[(combined >> 18) & 0x3f];
encoded += BASE64_CHARS[(combined >> 12) & 0x3f];
encoded +=
index + 1 < bytes.length ? BASE64_CHARS[(combined >> 6) & 0x3f] : "=";
encoded += index + 2 < bytes.length ? BASE64_CHARS[combined & 0x3f] : "=";
}
return encoded;
};
const buildNativeVoicepeakHtml = (
bytes: Uint8Array,
extension: "mp3" | "wav"
) => {
const mimeType = extension === "wav" ? "audio/wav" : "audio/mpeg";
const base64 = encodeBase64(bytes);
const source = `data:${mimeType};base64,${base64}`;
return `<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
/>
<style>
html, body {
margin: 0;
padding: 0;
background: transparent;
}
</style>
</head>
<body>
<audio id="voicepeak" autoplay playsinline src="${source}"></audio>
<script>
(function () {
var audio = document.getElementById("voicepeak");
if (!audio) return;
var start = function () {
var result = audio.play();
if (result && typeof result.catch === "function") {
result.catch(function () {});
}
};
document.addEventListener("DOMContentLoaded", start);
audio.addEventListener("canplay", start);
audio.addEventListener("ended", function () {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage("voicepeak-ended");
}
});
audio.addEventListener("error", function () {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage("voicepeak-error");
}
});
start();
})();
</script>
</body>
</html>`;
};
export const EMPTY_NATIVE_VOICEPEAK_HTML = `<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
</head>
<body></body>
</html>`;
export const createVoicepeakAudioSource = async (
bytes: Uint8Array,
extension: "mp3" | "wav" = "mp3"
): Promise<PreparedVoicepeakAudio> => {
if (Platform.OS === "web") {
const normalizedBytes = new Uint8Array(bytes.byteLength);
normalizedBytes.set(bytes);
const blob = new Blob([normalizedBytes], {
type: extension === "wav" ? "audio/wav" : "audio/mpeg",
});
const objectUrl = URL.createObjectURL(blob);
return {
kind: "expo-audio",
source: { uri: objectUrl },
cleanup: () => {
URL.revokeObjectURL(objectUrl);
},
};
}
if (Platform.OS === "ios") {
// WKWebViewの<audio>はAVAudioSessionを独自に切り替え、再生中の音楽を
// 停止させることがある。iOSでは一時ファイルをexpo-audioで再生し、
// setAudioModeAsyncのduckOthers設定を確実に適用する。
const file = new File(
Paths.cache,
`rikka-voicepeak-${Date.now()}-${Math.random()
.toString(36)
.slice(2)}.${extension}`
);
file.create({ overwrite: true });
file.write(bytes);
return {
kind: "expo-audio",
source: { uri: file.uri },
cleanup: () => {
if (file.exists) {
file.delete();
}
},
};
}
return {
kind: "native-webview",
html: buildNativeVoicepeakHtml(bytes, extension),
};
};
+178
View File
@@ -0,0 +1,178 @@
import { Platform } from "react-native";
import * as Updates from "expo-updates";
import Constants from "expo-constants";
import { AS } from "@/storageControl";
const STORAGE_KEY = "voicepeakDebugLogs";
const RETENTION_MILLISECONDS = 7 * 24 * 60 * 60 * 1000;
const MAX_LOG_ENTRIES = 5000;
export type VoicepeakDebugLogStatus = "attempting" | "success" | "error";
export type VoicepeakDebugLogEntry = {
id: string;
batchId?: string;
createdAt: string;
updatedAt: string;
status: VoicepeakDebugLogStatus;
text: string;
textLength: number;
codePointCount: number;
format: "mp3" | "wav";
baseUrl: string;
chunkIndex?: number;
totalChunks?: number;
attemptNumber?: number;
forceRequested?: boolean;
httpStatus?: number;
errorCode?: string;
requestId?: string;
cacheStatus?: string;
queueWaitMilliseconds?: number;
queueDepth?: number;
durationMilliseconds?: number;
responseBytes?: number;
warning?: string;
error?: string;
platform: string;
platformVersion: string;
appVersion?: string;
runtimeVersion?: string;
};
type NewVoicepeakDebugLog = Pick<
VoicepeakDebugLogEntry,
"text" | "format" | "baseUrl"
> &
Partial<
Pick<
VoicepeakDebugLogEntry,
| "batchId"
| "chunkIndex"
| "totalChunks"
| "attemptNumber"
| "forceRequested"
>
>;
type VoicepeakDebugLogResult = Partial<
Pick<
VoicepeakDebugLogEntry,
| "httpStatus"
| "errorCode"
| "requestId"
| "cacheStatus"
| "queueWaitMilliseconds"
| "queueDepth"
| "durationMilliseconds"
| "responseBytes"
| "warning"
| "error"
>
> & {
status: Exclude<VoicepeakDebugLogStatus, "attempting">;
};
let transactionQueue = Promise.resolve();
const serializeError = (error: unknown) => {
const message =
error instanceof Error ? `${error.name}: ${error.message}` : String(error);
return message.slice(0, 2000);
};
const readLogs = async (): Promise<VoicepeakDebugLogEntry[]> => {
try {
const stored = await AS.getItem(STORAGE_KEY);
return Array.isArray(stored) ? stored : [];
} catch {
return [];
}
};
const pruneLogs = (logs: VoicepeakDebugLogEntry[], now = Date.now()) =>
logs
.filter((log) => {
const timestamp = Date.parse(log.createdAt);
return Number.isFinite(timestamp) && now - timestamp < RETENTION_MILLISECONDS;
})
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, MAX_LOG_ENTRIES);
const runTransaction = <T>(operation: () => Promise<T>): Promise<T> => {
const result = transactionQueue.then(operation, operation);
transactionQueue = result.then(
() => undefined,
() => undefined
);
return result;
};
export const createVoicepeakDebugLog = async (
input: NewVoicepeakDebugLog
) => {
const now = new Date().toISOString();
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const entry: VoicepeakDebugLogEntry = {
id,
batchId: input.batchId,
createdAt: now,
updatedAt: now,
status: "attempting",
text: input.text,
textLength: input.text.length,
codePointCount: Array.from(input.text).length,
format: input.format,
baseUrl: input.baseUrl,
chunkIndex: input.chunkIndex,
totalChunks: input.totalChunks,
attemptNumber: input.attemptNumber,
forceRequested: input.forceRequested,
platform: Platform.OS,
platformVersion: String(Platform.Version),
appVersion: Constants.expoConfig?.version,
runtimeVersion: Updates.runtimeVersion ?? undefined,
};
await runTransaction(async () => {
const logs = pruneLogs([entry, ...(await readLogs())]);
await AS.setItem(STORAGE_KEY, logs);
});
return id;
};
export const completeVoicepeakDebugLog = async (
id: string,
result: VoicepeakDebugLogResult
) => {
await runTransaction(async () => {
const logs = await readLogs();
const updatedAt = new Date().toISOString();
const nextLogs = logs.map((log) =>
log.id === id
? {
...log,
...result,
warning: result.warning
? serializeError(result.warning)
: undefined,
error: result.error ? serializeError(result.error) : undefined,
updatedAt,
}
: log
);
await AS.setItem(STORAGE_KEY, pruneLogs(nextLogs));
});
};
export const getVoicepeakDebugLogs = async () =>
runTransaction(async () => {
const logs = pruneLogs(await readLogs());
await AS.setItem(STORAGE_KEY, logs);
return logs;
});
export const clearVoicepeakDebugLogs = async () =>
runTransaction(async () => {
await AS.removeItem(STORAGE_KEY).catch(() => undefined);
});
@@ -29,10 +29,8 @@ class LiveActivityForegroundService : Service() {
const val NOTIFICATION_ID = 8001
private const val TAG = "LiveActivityService"
private const val POLL_INTERVAL_MS = 15_000L
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"
private const val POSITION_API_URL =
"https://jr-shikoku-api-data-storage.haruk.in/tmp/currentPositions.json"
@Volatile
var isRunning = false
@@ -223,9 +221,9 @@ class LiveActivityForegroundService : Service() {
private fun pollTrainPosition() {
if (trainNumber.isEmpty()) return
try {
val json = fetchApi(PRIMARY_API_URL) ?: fetchApi(FALLBACK_API_URL)
val json = fetchApi(POSITION_API_URL)
if (json == null) {
Log.w(TAG, "Both APIs failed")
Log.w(TAG, "Position API failed")
return
}
@@ -330,7 +328,7 @@ class LiveActivityForegroundService : Service() {
*/
private fun pollStationTrains() {
try {
val json = fetchApi(PRIMARY_API_URL) ?: fetchApi(FALLBACK_API_URL) ?: return
val json = fetchApi(POSITION_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'
s.frameworks = 'ActivityKit', 'CoreLocation', 'UserNotifications'
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
@@ -196,6 +196,35 @@ 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
@@ -0,0 +1,193 @@
import CoreLocation
import ExpoModulesCore
import Foundation
import UserNotifications
struct LocationAnnouncementRecord: Record {
@Field var identifier: String = ""
@Field var stationName: String = ""
@Field var latitude: Double = 0
@Field var longitude: Double = 0
@Field var radiusMeters: Double = 800
@Field var soundFileName: String = ""
}
enum RikkaLocationAnnouncementManager {
private static let requestPrefix = "rikka-location-"
private static let maximumScheduledStations = 20
static func hasNotificationSound(fileName: String) -> Bool {
guard let soundURL = notificationSoundURL(fileName: fileName) else {
return false
}
return FileManager.default.fileExists(atPath: soundURL.path)
}
static func saveNotificationSound(
fileName: String,
base64Data: String,
promise: Promise
) {
guard let soundURL = notificationSoundURL(fileName: fileName) else {
promise.reject("ERR_NOTIFICATION_SOUND", "Invalid notification sound file name")
return
}
guard let data = Data(base64Encoded: base64Data) else {
promise.reject("ERR_NOTIFICATION_SOUND", "Invalid base64 audio data")
return
}
do {
try FileManager.default.createDirectory(
at: soundURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try data.write(to: soundURL, options: .atomic)
promise.resolve(nil)
} catch {
promise.reject("ERR_NOTIFICATION_SOUND", error.localizedDescription)
}
}
static func schedule(
trackingId: String,
announcements: [LocationAnnouncementRecord],
promise: Promise
) {
let center = UNUserNotificationCenter.current()
let prefix = identifierPrefix(trackingId: trackingId)
center.getNotificationSettings { settings in
guard settings.authorizationStatus == .authorized ||
settings.authorizationStatus == .provisional else {
promise.reject(
"ERR_NOTIFICATION_PERMISSION",
"Notification permission is required for Rikka announcements"
)
return
}
center.getPendingNotificationRequests { pendingRequests in
let oldIdentifiers = pendingRequests
.map(\.identifier)
.filter { $0.hasPrefix(prefix) }
center.removePendingNotificationRequests(withIdentifiers: oldIdentifiers)
let group = DispatchGroup()
let lock = NSLock()
var scheduledCount = 0
var firstError: Error?
for announcement in announcements.prefix(maximumScheduledStations) {
guard
announcement.latitude.isFinite,
announcement.longitude.isFinite,
(-90.0...90.0).contains(announcement.latitude),
(-180.0...180.0).contains(announcement.longitude),
hasNotificationSound(fileName: announcement.soundFileName)
else { continue }
let content = UNMutableNotificationContent()
content.title = "列車追従・りっかちゃん"
content.body = "次は、\(announcement.stationName)です。"
content.sound = UNNotificationSound(
named: UNNotificationSoundName(rawValue: announcement.soundFileName)
)
content.threadIdentifier = "rikka-train-follow"
content.userInfo = [
"type": "train-follow-announcement",
"stationName": announcement.stationName,
"trackingId": trackingId
]
let coordinate = CLLocationCoordinate2D(
latitude: announcement.latitude,
longitude: announcement.longitude
)
let radius = min(max(announcement.radiusMeters, 200), 2_000)
let identifier = "\(prefix)\(announcement.identifier)"
let region = CLCircularRegion(
center: coordinate,
radius: radius,
identifier: identifier
)
region.notifyOnEntry = true
region.notifyOnExit = false
let trigger = UNLocationNotificationTrigger(region: region, repeats: false)
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: trigger
)
group.enter()
center.add(request) { error in
lock.lock()
if let error, firstError == nil {
firstError = error
} else if error == nil {
scheduledCount += 1
}
lock.unlock()
group.leave()
}
}
group.notify(queue: .main) {
if scheduledCount == 0, let firstError {
promise.reject(
"ERR_LOCATION_ANNOUNCEMENT",
firstError.localizedDescription
)
} else {
promise.resolve(scheduledCount)
}
}
}
}
}
static func cancel(trackingId: String, promise: Promise) {
let center = UNUserNotificationCenter.current()
let prefix = identifierPrefix(trackingId: trackingId)
center.getPendingNotificationRequests { pendingRequests in
let identifiers = pendingRequests
.map(\.identifier)
.filter { $0.hasPrefix(prefix) }
center.removePendingNotificationRequests(withIdentifiers: identifiers)
promise.resolve(nil)
}
}
private static func notificationSoundURL(fileName: String) -> URL? {
let validCharacters = CharacterSet.alphanumerics.union(
CharacterSet(charactersIn: "-_.")
)
let allowedExtensions = ["wav", "caf", "aiff"]
guard
!fileName.isEmpty,
fileName.unicodeScalars.allSatisfy({ validCharacters.contains($0) }),
allowedExtensions.contains((fileName as NSString).pathExtension.lowercased()),
let libraryURL = FileManager.default.urls(
for: .libraryDirectory,
in: .userDomainMask
).first
else { return nil }
return libraryURL
.appendingPathComponent("Sounds", isDirectory: true)
.appendingPathComponent(fileName, isDirectory: false)
}
private static func identifierPrefix(trackingId: String) -> String {
let safeTrackingId = trackingId.replacingOccurrences(
of: "[^A-Za-z0-9_-]",
with: "-",
options: .regularExpression
)
return "\(requestPrefix)\(safeTrackingId)-"
}
}
+43 -3
View File
@@ -122,6 +122,15 @@ export interface StationLockState {
trains?: StationTrainInfo[];
}
export interface LocationAnnouncement {
identifier: string;
stationName: string;
latitude: number;
longitude: number;
radiusMeters: number;
soundFileName: string;
}
export interface StationTrainInfo {
time: string;
typeName: string;
@@ -243,11 +252,9 @@ if (ExpoLiveActivityModule) {
* iOS 16.2+ の実機かつユーザーが許可している場合のみ true。
* Android では常に true。
*
* NOTE: 一時的に無効化中 — 常に false を返す
*/
export function isAvailable(): boolean {
return false;
// return ExpoLiveActivityModule?.isAvailable() ?? false;
return ExpoLiveActivityModule?.isAvailable() ?? false;
}
/**
@@ -422,6 +429,39 @@ export function getActiveStationLockActivities(): string[] {
return ExpoLiveActivityModule?.getActiveStationLockActivities() ?? [];
}
// 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
/**
+403 -162
View File
@@ -250,10 +250,14 @@ const buildOperationPageScript = (layout: {
'.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:block !important;text-align:right !important;margin:12px 0 8px !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-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;}',
@@ -748,28 +752,23 @@ const buildOperationPageScript = (layout: {
return bestText;
}
function buildXCoverSummary(items) {
var entries = (items || []).map(function(item) {
var title = strip(item && item.title) || '運行情報';
var subTitle = strip(item && item.subTitle);
return subTitle ? title + '' + subTitle : title;
}).filter(function(text) {
return !!text;
});
if (!entries.length) {
return '現在表示中の運行情報はありません。';
}
if (entries.length === 1) {
return entries[0];
}
if (entries.length === 2) {
return entries[0] + ' / ' + entries[1];
}
return entries.slice(0, 3).join(' / ') + (entries.length > 3 ? ' ほか' : '');
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) {
@@ -792,27 +791,41 @@ const buildOperationPageScript = (layout: {
function createXDetailUnits(ctx, item, bodyWidth) {
var units = [];
var pendingHeading = '';
var sections = [];
var currentSection = { heading: '', body: [] };
var blocks = item && item.blocks ? item.blocks : [];
blocks.forEach(function(block) {
if (block.type === 'badge') {
pendingHeading = strip(block.text);
if (currentSection.heading || currentSection.body.length) {
sections.push(currentSection);
}
currentSection = { heading: strip(block.text), body: [] };
return;
}
ctx.font = "400 28px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
var bodyLines = wrapText(ctx, strip(block.text), bodyWidth);
if (!bodyLines.length && !pendingHeading) 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 = pendingHeading ? wrapText(ctx, pendingHeading, bodyWidth - 24) : [];
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,
headingText: pendingHeading,
lineHeight: 39
});
pendingHeading = '';
});
if (!units.length) {
@@ -820,7 +833,6 @@ const buildOperationPageScript = (layout: {
units.push({
headingLines: [],
bodyLines: wrapText(ctx, '詳細情報はJR四国公式の運行情報をご確認ください。', bodyWidth),
headingText: '',
lineHeight: 39
});
}
@@ -828,6 +840,27 @@ const buildOperationPageScript = (layout: {
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;
@@ -883,77 +916,188 @@ const buildOperationPageScript = (layout: {
};
}
function createEmptyXDetailPage() {
return { items: [], usedHeight: 0 };
}
function fillXPageUnits(units, unitIndex, availableHeight) {
var pageUnits = [];
var remainingHeight = availableHeight;
function paginateXDetailPages(ctx, items, contentWidth) {
var bodyWidth = contentWidth - 32;
var maxHeight = X_CAPTURE_CONTENT_BOTTOM - X_CAPTURE_CONTENT_TOP;
var pages = [createEmptyXDetailPage()];
while (unitIndex < units.length) {
var budget = remainingHeight;
if (pageUnits.length) {
budget -= X_CAPTURE_UNIT_GAP;
}
if (budget <= 0) {
break;
}
for (var itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
var item = items[itemIndex];
var units = createXDetailUnits(ctx, item, bodyWidth);
var unitIndex = 0;
var continued = false;
var chunkInfo = buildXUnitChunk(units[unitIndex], budget);
if (!chunkInfo) {
break;
}
while (unitIndex < units.length) {
var page = pages[pages.length - 1];
var gapBeforeItem = page.items.length ? X_CAPTURE_ITEM_GAP : 0;
var header = measureXDetailHeader(ctx, item, contentWidth, continued);
var availableForStart = maxHeight - page.usedHeight - gapBeforeItem - header.height;
var preview = buildXUnitChunk(units[unitIndex], availableForStart);
if (pageUnits.length) {
remainingHeight -= X_CAPTURE_UNIT_GAP;
}
pageUnits.push(chunkInfo.chunk);
remainingHeight -= chunkInfo.height;
if (!preview) {
if (page.items.length) {
pages.push(createEmptyXDetailPage());
continue;
}
return null;
}
var pageItem = {
header: header,
units: []
};
if (gapBeforeItem) {
page.usedHeight += gapBeforeItem;
}
page.items.push(pageItem);
page.usedHeight += header.height;
while (unitIndex < units.length) {
var availableHeight = maxHeight - page.usedHeight;
var chunkInfo = buildXUnitChunk(units[unitIndex], availableHeight);
if (!chunkInfo) {
break;
}
pageItem.units.push(chunkInfo.chunk);
page.usedHeight += chunkInfo.height;
if (chunkInfo.consumed) {
unitIndex += 1;
} else if (chunkInfo.rest) {
units[unitIndex] = chunkInfo.rest;
}
if (unitIndex < units.length) {
page.usedHeight += X_CAPTURE_UNIT_GAP;
}
}
if (unitIndex < units.length) {
pages.push(createEmptyXDetailPage());
continued = true;
}
if (chunkInfo.consumed) {
unitIndex += 1;
} else if (chunkInfo.rest) {
units[unitIndex] = chunkInfo.rest;
}
}
return pages.filter(function(page) {
return page.items.length > 0;
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) {
@@ -1055,7 +1199,7 @@ const buildOperationPageScript = (layout: {
});
}
async function buildXCoverPage(items, totalPages, pageIndex) {
async function buildXHeroPage(page, pageIndex, totalPages) {
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');
if (!ctx) return null;
@@ -1064,51 +1208,79 @@ const buildOperationPageScript = (layout: {
canvas.height = X_CAPTURE_PAGE_HEIGHT;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawXPageChrome(ctx, pageIndex, totalPages, '運行情報・路線図');
drawXPageChrome(ctx, pageIndex, totalPages, 'X投稿向け画像');
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);
var summary = buildXCoverSummary(items);
ctx.font = "700 38px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
var summaryLines = wrapText(ctx, summary, X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X * 2 - 44).slice(0, 4);
var summaryHeight = Math.max(138, 32 + summaryLines.length * 46);
ctx.fillStyle = '#0f76a8';
ctx.fillRect(X_CAPTURE_SAFE_X, 186, X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X * 2, summaryHeight);
ctx.fillStyle = '#ffffff';
summaryLines.forEach(function(line, index) {
ctx.fillText(line, X_CAPTURE_SAFE_X + 24, 236 + index * 46);
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(X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X * 2 - 30);
mapCanvas = await buildMapImage(contentWidth - 32);
} catch (e) {
return null;
}
if (!mapCanvas) return null;
var mapCardY = 186 + summaryHeight + 34;
var mapX = X_CAPTURE_SAFE_X + 15;
var mapY = mapCardY + 16;
ctx.fillStyle = '#ffffff';
var mapTop = heroTop + hero.height + 24;
ctx.strokeStyle = '#c7dcea';
ctx.lineWidth = 2;
ctx.strokeRect(X_CAPTURE_SAFE_X, mapCardY, X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X * 2, mapCanvas.height + 32);
ctx.drawImage(mapCanvas, mapX, mapY, mapCanvas.width, mapCanvas.height);
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 latestUpdatedAt = getLatestUpdatedAt(items);
var infoY = mapCardY + mapCanvas.height + 72;
ctx.fillStyle = '#0f1720';
ctx.font = "700 28px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
ctx.fillText('現在表示中の路線図と運行情報', X_CAPTURE_SAFE_X, infoY);
if (latestUpdatedAt) {
ctx.fillStyle = '#4b5563';
ctx.font = "500 24px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
ctx.fillText(latestUpdatedAt, X_CAPTURE_SAFE_X, infoY + 40);
}
if (totalPages > 1) {
ctx.fillStyle = '#0099CB';
ctx.font = "800 30px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
ctx.fillText('詳細は次の画像へ →', X_CAPTURE_SAFE_X, infoY + 94);
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);
@@ -1124,21 +1296,50 @@ const buildOperationPageScript = (layout: {
canvas.height = X_CAPTURE_PAGE_HEIGHT;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawXPageChrome(ctx, pageIndex, totalPages, '運行情報詳細');
drawXPageChrome(ctx, pageIndex, totalPages, '詳細情報');
var y = X_CAPTURE_CONTENT_TOP;
var width = X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X * 2;
page.items.forEach(function(pageItem, itemIndex) {
if (itemIndex > 0) {
y += X_CAPTURE_ITEM_GAP;
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;
}
drawXDetailHeaderBlock(ctx, pageItem.header, X_CAPTURE_SAFE_X, y, width);
y += pageItem.header.height;
pageItem.units.forEach(function(unit, unitIndex) {
drawXUnitBlock(ctx, unit, X_CAPTURE_SAFE_X, y, width);
y += unit.height;
if (unitIndex < pageItem.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;
}
});
});
@@ -1147,6 +1348,16 @@ const buildOperationPageScript = (layout: {
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) {
@@ -1161,43 +1372,52 @@ const buildOperationPageScript = (layout: {
return;
}
var detailPages = paginateXDetailPages(measureCtx, items, X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X * 2);
if (!detailPages || detailPages.length > 3) {
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 = 1 + detailPages.length;
var totalPages = pages.length;
var timestamp = strip(fileNameBase) || String(Date.now());
var cover = await buildXCoverPage(items, totalPages, 0);
if (!cover) {
postMessage({ error: true, reason: 'x-map' });
return;
}
var pages = [cover];
detailPages.forEach(function(page, index) {
var detailCanvas = buildXDetailPage(page, index + 1, totalPages);
if (detailCanvas) {
pages.push(detailCanvas);
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;
}
});
if (pages.length !== totalPages) {
postMessage({ error: true });
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 < pages.length; index += 1) {
for (var index = 0; index < renderedPages.length; index += 1) {
var rendered = renderedPages[index];
postMessage({
batchId: batchId,
batchIndex: index,
batchTotal: pages.length,
dataUrl: pages[index].toDataURL('image/png'),
fileName: index === 0
? 'operation-info-x-01-map-' + timestamp + '.png'
: 'operation-info-x-0' + String(index + 1) + '-detail-' + timestamp + '.png'
batchTotal: renderedPages.length,
dataUrl: rendered.canvas.toDataURL('image/png'),
fileName: 'operation-info-x-' + formatXPageFileIndex(index) + '-' + rendered.token + '-' + rendered.kind + '-' + timestamp + '.png'
});
}
} catch (e) {
@@ -1800,6 +2020,27 @@ const buildOperationPageScript = (layout: {
});
};
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);
}
+93 -50
View File
@@ -6,9 +6,14 @@ import React, {
useRef,
FC,
} from "react";
import { InteractionManager } from "react-native";
import useInterval from "../lib/useInterval";
import { observedFetchJson, observedFetchText } from "@/lib/observability/network/observedFetch";
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";
const setoStationID = [
"Y00",
@@ -362,98 +367,136 @@ type props = { children: React.ReactNode };
export const AreaInfoProvider: FC<props> = ({ children }) => {
const [areaInfo, setAreaInfo] = useState("");
const [areaIconBadgeText, setAreaIconBadgeText] = useState("");
const [areaStationID, setAreaStationID] = useState([]);
const [areaStationID, setAreaStationID] = useState<string[]>([]);
const [isInfo, setIsInfo] = useState(false);
const areaDescriptionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initialFetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(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 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 scheduleAreaDescriptionFetch = () => {
if (areaDescriptionTimeoutRef.current) {
clearTimeout(areaDescriptionTimeoutRef.current);
}
areaDescriptionTimeoutRef.current = setTimeout(() => {
areaDescriptionTimeoutRef.current = null;
fetchAreaDescription();
}, 800);
const scheduleNextFetch = (delayMs: number) => {
if (!isMountedRef.current || !isActiveRef.current) return;
clearNextFetchTimeout();
nextFetchTimeoutRef.current = setTimeout(() => {
nextFetchTimeoutRef.current = null;
getAreaDataRef.current();
}, delayMs);
};
const getAreaData = () => {
observedFetchJson<any>("https://n8n.haruk.in/webhook/jr-shikoku-trainfo-flag", {
endpoint: "operation_info_flag",
source: "n8n",
if (isFetchingRef.current || !isActiveRef.current) return;
isFetchingRef.current = true;
observedFetchJson<OperationInfoSnapshot>(API_ENDPOINTS.OPERATION_INFO, {
endpoint: "operation_info",
source: "static_storage",
userVisible: true,
preload: true,
fetchPriority: "medium",
timeoutMs: 10000,
retry: true,
urlPathTemplate: "/webhook/jr-shikoku-trainfo-flag",
cache: "no-store",
urlPathTemplate: "/operation-info/jr-shikoku/latest.json",
})
.then((d) => {
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);
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
);
const text = activeLineInfo.map((e) => {
return `${areaStationPair[e.area].id}`;
return areaStationPair[e.area as keyof typeof areaStationPair].id;
});
let stationIDList = [];
let stationIDList: string[] = [];
activeLineInfo.forEach((e) => {
stationIDList = stationIDList.concat(
areaStationPair[e.area].stationID
areaStationPair[e.area as keyof typeof areaStationPair].stationID
);
});
const info = genelicInfo[0].status.includes("nodelay") ? true : false;
const info =
typeof generalInfo?.status === "string" &&
generalInfo.status.includes("nodelay");
setIsInfo(info);
setAreaStationID(stationIDList);
setAreaIconBadgeText(
text.length == 0 ? (info ? "i" : "!") : text.join(",")
);
if (stationIDList.length > 0) {
scheduleAreaDescriptionFetch();
setAreaInfo(d.compatibility.operationInfoText);
} else {
setAreaInfo("");
}
})
.catch(() => {});
.catch(() => {
scheduleNextFetch(OPERATION_INFO_STALE_RETRY_MS);
})
.finally(() => {
isFetchingRef.current = false;
});
};
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;
getAreaData();
getAreaDataRef.current();
}, 1200);
});
return () => {
const subscription = AppState.addEventListener("change", (nextState) => {
task.cancel?.();
if (initialFetchTimeoutRef.current) {
clearTimeout(initialFetchTimeoutRef.current);
initialFetchTimeoutRef.current = null;
}
if (areaDescriptionTimeoutRef.current) {
clearTimeout(areaDescriptionTimeoutRef.current);
areaDescriptionTimeoutRef.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();
};
}, []);
useInterval(getAreaData, 60000); //60秒毎に全在線列車取得
return (
<AreaInfoContext.Provider
value={{
+43 -5
View File
@@ -80,6 +80,10 @@ const initialState = {
setTrainMenu: (e) => {},
updatePermission: false,
setUpdatePermission: (e) => {},
/** バックエンドが返したユーザーロール */
userPermissionRole: "",
/** crew/administrator向け音声機能の表示・利用権限 */
restrictedSoundPermission: false,
/** 各情報ソースの利用権限 */
dataSourcePermission: { unyohub: false, elesite: false } as {
unyohub: boolean;
@@ -160,25 +164,57 @@ 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) return;
if (!expoPushToken) {
setUserPermissionRole("");
setUpdatePermission(false);
setRestrictedSoundPermission(false);
setDataSourcePermission({ unyohub: false, elesite: false });
return;
}
setUserPermissionRole("");
setUpdatePermission(false);
setRestrictedSoundPermission(false);
setDataSourcePermission({ unyohub: false, elesite: false });
const permissionController = new AbortController();
fetch(
`${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 ?? "";
setUpdatePermission(role === "administrator");
const normalizedRole = role.trim().toLowerCase();
const isAdministrator = normalizedRole === "administrator";
setUserPermissionRole(normalizedRole);
setUpdatePermission(isAdministrator);
setRestrictedSoundPermission(
normalizedRole === "crew" || isAdministrator,
);
setDataSourcePermission({
unyohub: role === "administrator" || role === "unyoHubEditor",
elesite: role === "administrator" || role === "eleSiteEditor",
unyohub: isAdministrator || role === "unyoHubEditor",
elesite: isAdministrator || role === "eleSiteEditor",
});
})
.catch(() => {});
.catch(() => {
if (permissionController.signal.aborted) return;
setUserPermissionRole("");
setUpdatePermission(false);
setRestrictedSoundPermission(false);
setDataSourcePermission({ unyohub: false, elesite: false });
});
return () => permissionController.abort();
}, [expoPushToken, backendApiBaseUrl]);
//列車情報表示関連
@@ -558,6 +594,8 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
setTrainMenu,
updatePermission,
setUpdatePermission,
userPermissionRole,
restrictedSoundPermission,
dataSourcePermission,
injectJavascript,
injectJavascriptBeforeContentLoaded,
+13 -14
View File
@@ -10,8 +10,6 @@ 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)
}
@@ -32,20 +30,21 @@ struct OperationInfoProvider: TimelineProvider {
}
private func fetchData(completion: @escaping (OperationEntry) -> Void) {
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
fetchOperationInfoSnapshot { result in
let operationInfoText: String
switch result {
case .success(let snapshot):
operationInfoText = snapshot.compatibility.operationInfoText
case .failure:
operationInfoText = ""
}
let displayText = text.replacingOccurrences(of: "^", with: "\n")
let displayText = operationInfoText.isEmpty
? "通常運行中です。"
: operationInfoText.replacingOccurrences(of: "^", with: "\n")
completion(OperationEntry(date: Date(), text: displayText, isLoading: false))
}.resume()
}
}
}
+47
View File
@@ -1,6 +1,9 @@
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"
@@ -13,6 +16,50 @@ 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
}
+5 -12
View File
@@ -14,7 +14,6 @@ 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: "未読取")
@@ -59,17 +58,11 @@ struct ShortcutProvider: TimelineProvider {
//
group.enter()
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()
fetchOperationInfoSnapshot { result in
defer { group.leave() }
if case .success(let snapshot) = result {
hasInfo = snapshot.compatibility.hasOperationInfo
}
}
// Felica
+2
View File
@@ -3,6 +3,8 @@
* 使
*/
export * from "./operationInfo";
/**
*
*/
+15
View File
@@ -0,0 +1,15 @@
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[];
};
};