Compare commits
137
Commits
@@ -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',
|
||||
@@ -74,7 +75,10 @@ LogBox.ignoreLogs([
|
||||
"ColorPropType will be removed",
|
||||
]);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
const nativeScreensDisabled =
|
||||
Platform.OS === "ios" || Platform.OS === "android";
|
||||
|
||||
if (nativeScreensDisabled) {
|
||||
enableFreeze(false);
|
||||
enableScreens(false);
|
||||
}
|
||||
@@ -87,12 +91,17 @@ if (Platform.OS === "android") {
|
||||
|
||||
export default Sentry.wrap(function App() {
|
||||
useEffect(() => {
|
||||
const nativeScreensMode =
|
||||
Platform.OS === "ios" ? "screens-disabled" : "default";
|
||||
const nativeScreensMode = nativeScreensDisabled
|
||||
? "screens-disabled"
|
||||
: "default";
|
||||
const rootTabSceneMode =
|
||||
Platform.OS === "android" ? "opacity-fixed-z-v1" : "default";
|
||||
Sentry.setTag("native_screens_mode", nativeScreensMode);
|
||||
Sentry.setTag("root_tab_scene_mode", rootTabSceneMode);
|
||||
Sentry.setContext("runtime_navigation_flags", {
|
||||
platform: Platform.OS,
|
||||
nativeScreensMode,
|
||||
rootTabSceneMode,
|
||||
});
|
||||
Sentry.addBreadcrumb({
|
||||
category: "runtime.flags",
|
||||
@@ -101,6 +110,7 @@ export default Sentry.wrap(function App() {
|
||||
data: {
|
||||
platform: Platform.OS,
|
||||
nativeScreensMode,
|
||||
rootTabSceneMode,
|
||||
},
|
||||
});
|
||||
void startAppLifecycleCrashSentinel({ nativeScreensMode });
|
||||
@@ -113,6 +123,10 @@ export default Sentry.wrap(function App() {
|
||||
UpdateAsync();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void migrateLegacyVoicepeakSettings();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const openFelicaPage = (retryCount = 0) => {
|
||||
if (!rootNavigationRef.isReady()) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { NavigationContainer, DarkTheme, DefaultTheme } from "@react-navigation/native";
|
||||
import { NavigationContainer, DarkTheme, DefaultTheme, type EventArg } from "@react-navigation/native";
|
||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||
import { Animated, Platform, ActivityIndicator, View, StyleSheet, StatusBar, useWindowDimensions, InteractionManager } from "react-native";
|
||||
import { useNavigationState } from "@react-navigation/native";
|
||||
@@ -15,10 +15,16 @@ import lineColorList from "./assets/originData/lineColorList";
|
||||
import { stationIDPair } from "./lib/getStationList";
|
||||
import "./components/ActionSheetComponents/sheets";
|
||||
import { lastObservedRootRouteRef, positionsLifecycleRef, rootNavigationRef } from "./lib/rootNavigation";
|
||||
import {
|
||||
startupExplicitTargetRef,
|
||||
waitForStartupNavigationResolution,
|
||||
} from "./lib/rootNavigation";
|
||||
import { fixedColors } from "./lib/theme/colors";
|
||||
import { useThemeColors } from "./lib/theme";
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
import WebView from "react-native-webview";
|
||||
import { AS } from "./storageControl";
|
||||
import { STORAGE_KEYS } from "./constants/storage";
|
||||
import {
|
||||
recordAppLifecycleRootNavigation,
|
||||
setAppLifecycleWebViewActive,
|
||||
@@ -247,6 +253,7 @@ export function AppContainer() {
|
||||
const { selectedLine } = useTrainMenu();
|
||||
const { width, height } = useWindowDimensions();
|
||||
const [isExtraWindowOpen, setIsExtraWindowOpen] = React.useState(false);
|
||||
const [startupInitialRoute, setStartupInitialRoute] = React.useState<keyof RootTabParamList | null>(null);
|
||||
const [hasVisitedPositions, setHasVisitedPositions] = React.useState(false);
|
||||
const [hasVisitedInformation, setHasVisitedInformation] = React.useState(false);
|
||||
const [currentRootRoute, setCurrentRootRoute] = React.useState<keyof RootTabParamList | null>(null);
|
||||
@@ -278,6 +285,45 @@ export function AppContainer() {
|
||||
const { isDark } = useThemeColors();
|
||||
const lastRootNavStateRef = React.useRef("");
|
||||
const lineColorDark = lineColor ? darkenHex(lineColor, 0.78) : null;
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
await waitForStartupNavigationResolution();
|
||||
if (cancelled) return;
|
||||
|
||||
let nextInitialRoute: keyof RootTabParamList = "topMenu";
|
||||
if (!startupExplicitTargetRef.current) {
|
||||
try {
|
||||
const startPage = await AS.getItem(STORAGE_KEYS.START_PAGE);
|
||||
if (startPage === "true") {
|
||||
nextInitialRoute = "positions";
|
||||
}
|
||||
} catch {
|
||||
// Keep the default route when the setting is unset or unreadable.
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setStartupInitialRoute(nextInitialRoute);
|
||||
setHasVisitedPositions(nextInitialRoute === "positions");
|
||||
Sentry.addBreadcrumb({
|
||||
category: "nav.root",
|
||||
level: "info",
|
||||
message: "startup initial root route resolved",
|
||||
data: {
|
||||
startupExplicitTarget: startupExplicitTargetRef.current,
|
||||
initialRoute: nextInitialRoute,
|
||||
},
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
const linking = {
|
||||
prefixes: ["jrshikoku://"],
|
||||
config: {
|
||||
@@ -376,6 +422,7 @@ export function AppContainer() {
|
||||
|
||||
const [fontLoaded, error] = useFonts({
|
||||
"JR-Nishi": require("./assets/fonts/jr-nishi.otf"),
|
||||
"JR-WEST-PLUS": require("./assets/fonts/JR-WEST-PLUS.otf"),
|
||||
Zou: require("./assets/fonts/DelaGothicOne-Regular.ttf"),
|
||||
"JNR-font": require("./assets/fonts/JNRfont_pict.ttf"),
|
||||
"DiaPro": require("./assets/fonts/DiaPro-Regular.otf"),
|
||||
@@ -458,7 +505,7 @@ export function AppContainer() {
|
||||
};
|
||||
}, [currentRootRoute, fontLoaded, hasVisitedInformation, navigationReady]);
|
||||
|
||||
if (!fontLoaded) {
|
||||
if (!fontLoaded || !startupInitialRoute) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<ActivityIndicator size="large" />
|
||||
@@ -498,9 +545,9 @@ export function AppContainer() {
|
||||
<Tab.Navigator
|
||||
id="rootTabs"
|
||||
detachInactiveScreens={false}
|
||||
initialRouteName="topMenu"
|
||||
initialRouteName={startupInitialRoute}
|
||||
screenListeners={({ route }) => ({
|
||||
tabPress: (event) => {
|
||||
tabPress: (event: EventArg<"tabPress", true>) => {
|
||||
const rootState = rootNavigationRef.getState();
|
||||
const activeRootRouteName =
|
||||
(rootState?.routes?.[rootState.index ?? 0]?.name as keyof RootTabParamList | undefined) ??
|
||||
@@ -532,16 +579,30 @@ export function AppContainer() {
|
||||
|
||||
if (blockingUnstableExit) {
|
||||
event.preventDefault();
|
||||
positionsLifecycleRef.current.resetBeforeLeave?.();
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
rootNavigationRef.navigate(route.name as never);
|
||||
}, 0);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (leavingUnstablePositions) {
|
||||
event.preventDefault();
|
||||
positionsLifecycleRef.current.resetBeforeLeave?.();
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
rootNavigationRef.navigate(route.name as never);
|
||||
}, 0);
|
||||
});
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.root",
|
||||
level: "info",
|
||||
message: "positions root exit blocked while session unstable",
|
||||
data: { route: route.name },
|
||||
message: "positions root exit reset and continued while session unstable",
|
||||
data: {
|
||||
route: route.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
+2
-24
@@ -19,10 +19,6 @@ import { useFavoriteStation } from "@/stateBox/useFavoriteStation";
|
||||
import { optionData } from "@/lib/stackOption";
|
||||
import { AllTrainDiagramView } from "@/components/AllTrainDiagramView";
|
||||
import { useNavigation, useIsFocused } from "@react-navigation/native";
|
||||
import {
|
||||
startupExplicitTargetRef,
|
||||
waitForStartupNavigationResolution,
|
||||
} from "@/lib/rootNavigation";
|
||||
import { news } from "@/config/newsUpdate";
|
||||
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
|
||||
import GeneralWebView from "@/GeneralWebView";
|
||||
@@ -69,23 +65,6 @@ export function MenuPage() {
|
||||
}, [height, isFocused, width]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await waitForStartupNavigationResolution();
|
||||
if (cancelled) return;
|
||||
|
||||
const res = await AS.getItem(STORAGE_KEYS.START_PAGE);
|
||||
if (res !== "true" || startupExplicitTargetRef.current) return;
|
||||
|
||||
navigation.navigate("positions");
|
||||
} catch (e) {
|
||||
//6.0以降false
|
||||
AS.setItem(STORAGE_KEYS.START_PAGE, "false");
|
||||
}
|
||||
})();
|
||||
|
||||
//ニュース表示
|
||||
AS.getItem(STORAGE_KEYS.NEWS_STATUS)
|
||||
.then((d) => {
|
||||
@@ -98,9 +77,7 @@ export function MenuPage() {
|
||||
})
|
||||
.catch((error) => logger.error("Error fetching icon setting:", error));
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
const scrollRef = useRef(null);
|
||||
@@ -182,6 +159,7 @@ export function MenuPage() {
|
||||
return (
|
||||
<Stack.Navigator
|
||||
id={null}
|
||||
detachInactiveScreens={false}
|
||||
screenOptions={{ cardStyle: { backgroundColor: bgColor } }}
|
||||
screenListeners={({ route, navigation: stackNav }) => {
|
||||
stackNavRef.current = stackNav;
|
||||
|
||||
@@ -188,6 +188,7 @@ export const Top = () => {
|
||||
return (
|
||||
<Stack.Navigator
|
||||
id={null}
|
||||
detachInactiveScreens={false}
|
||||
screenOptions={{ cardStyle: { backgroundColor: bgColor } }}
|
||||
screenListeners={({ route, navigation: stackNav }) => {
|
||||
stackNavRef.current = stackNav;
|
||||
|
||||
@@ -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",
|
||||
@@ -117,6 +114,7 @@
|
||||
{
|
||||
"fonts": [
|
||||
"./assets/fonts/jr-nishi.otf",
|
||||
"./assets/fonts/JR-WEST-PLUS.otf",
|
||||
"./assets/fonts/DelaGothicOne-Regular.ttf",
|
||||
"./assets/fonts/JNRfont_pict.ttf",
|
||||
"./assets/fonts/DiaPro-Regular.otf"
|
||||
@@ -133,7 +131,7 @@
|
||||
[
|
||||
"expo-location",
|
||||
{
|
||||
"locationWhenInUsePermission": "この位置情報は、リンク画面で現在地側近の駅情報を取得するのに使用されます。"
|
||||
"locationWhenInUsePermission": "現在地付近の駅表示と、列車追従中に次の停車駅への接近をりっかちゃん音声で通知するために使用します。"
|
||||
}
|
||||
],
|
||||
[
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,27 @@
|
||||
西日本方向ロゴ拡張「JR-WEST-PLUS」
|
||||
|
||||
2026/07/11 by Mameh1nata
|
||||
|
||||
「回送」とか専用の書体の方がかっこよくね?と思い制作したフォントです
|
||||
現時点で対応している種別は以下の通りです
|
||||
回送 試運転 貸切 修学旅行 救援 配給 訓練 検測 競技 検定 教習
|
||||
|
||||
ver1.0 プライベート公開
|
||||
ver1.01 修学旅行を追加
|
||||
ver1.1 救援 配給 訓練の追加
|
||||
ver1.2 競技 検定 教習の追加
|
||||
|
||||
-注意-
|
||||
・このフォントは西日本方向幕ロゴの制作者とは何の関係もありません
|
||||
・常識の範囲内で使うことをお勧めします(常識の範囲外でも自己責任でお願いします)
|
||||
・文字サイズが多少違ったりするかもしれませんがご了承ください
|
||||
・フォント初制作なのであまりにも致命的なミス以外は許してください
|
||||
|
||||
配布元: https://uu.getuploader.com/mameh1nata_dustbox/download/1
|
||||
|
||||
アプリ同梱版の調整:
|
||||
・JR-Nishi と字面を揃えるため、各グリフの表示領域を 1040 units に正規化
|
||||
・送り幅は 930 units とし、広げた字形の右側はアプリ側で小幅の DiaPro NBSP を付加して保護
|
||||
・縦の表示領域を 836 units、ベースラインを -86〜750 に正規化
|
||||
・フォント全体の ascender / descender を JR-Nishi と同じ 781 / -118 に調整
|
||||
・文字コードと内部フォント名は元ファイルから維持
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { FC, useMemo, useState } from "react";
|
||||
import React, { FC, useMemo } from "react";
|
||||
import { Text, View, TextStyle, TouchableOpacity } from "react-native";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import { migrateTrainName } from "../../../lib/eachTrainInfoCoreLib/migrateTrainName";
|
||||
@@ -16,6 +16,7 @@ import type { NavigateFunction } from "@/types";
|
||||
import { useUnyohub } from "@/stateBox/useUnyohub";
|
||||
import { useElesite } from "@/stateBox/useElesite";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { formatWrappedTrainType, shouldWrapTrainType } from "@/lib/trainTypeWrap";
|
||||
import { useResponsive } from "@/lib/responsive";
|
||||
import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
|
||||
|
||||
@@ -75,7 +76,7 @@ export const HeaderText: FC<Props> = ({
|
||||
const [
|
||||
typeName,
|
||||
trainName,
|
||||
fontAvailable,
|
||||
fontFamily,
|
||||
isOneMan,
|
||||
infogram,
|
||||
priority,
|
||||
@@ -96,7 +97,7 @@ export const HeaderText: FC<Props> = ({
|
||||
to_data,
|
||||
directions,
|
||||
} = result;
|
||||
const [typeString, fontAvailable, isOneMan] = getStringConfig(
|
||||
const [typeString, fontFamily, isOneMan] = getStringConfig(
|
||||
type,
|
||||
trainNum,
|
||||
);
|
||||
@@ -108,7 +109,7 @@ export const HeaderText: FC<Props> = ({
|
||||
(train_num_distance !== "" && !isNaN(parseInt(train_num_distance))
|
||||
? ` ${parseInt(trainNum) - parseInt(train_num_distance)}号`
|
||||
: ""),
|
||||
fontAvailable,
|
||||
fontFamily,
|
||||
isOneMan,
|
||||
infogram,
|
||||
priority,
|
||||
@@ -121,7 +122,7 @@ export const HeaderText: FC<Props> = ({
|
||||
return [
|
||||
typeString,
|
||||
"",
|
||||
fontAvailable,
|
||||
fontFamily,
|
||||
isOneMan,
|
||||
infogram,
|
||||
priority,
|
||||
@@ -134,7 +135,7 @@ export const HeaderText: FC<Props> = ({
|
||||
return [
|
||||
typeString,
|
||||
`${to_data}行き`,
|
||||
fontAvailable,
|
||||
fontFamily,
|
||||
isOneMan,
|
||||
infogram,
|
||||
priority,
|
||||
@@ -149,7 +150,7 @@ export const HeaderText: FC<Props> = ({
|
||||
migrateTrainName(
|
||||
`${trainData[trainData.length - 1].split(",")[0]}行き`,
|
||||
),
|
||||
fontAvailable,
|
||||
fontFamily,
|
||||
isOneMan,
|
||||
infogram,
|
||||
priority,
|
||||
@@ -211,7 +212,7 @@ export const HeaderText: FC<Props> = ({
|
||||
hasUnyohubFormation ||
|
||||
hasElesiteFormation;
|
||||
|
||||
const [isWrapped, setIsWrapped] = useState(false);
|
||||
const wrapType = shouldWrapTrainType(typeName, trainName);
|
||||
|
||||
const openTrainInfoUrl = () => {
|
||||
if (!trainInfoUrl) return;
|
||||
@@ -251,15 +252,23 @@ export const HeaderText: FC<Props> = ({
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
numberOfLines={wrapType ? 2 : 1}
|
||||
style={{
|
||||
fontSize: fontScale(20),
|
||||
color: fixed.textOnPrimary,
|
||||
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
|
||||
fontWeight: !fontAvailable ? "bold" : undefined,
|
||||
fontFamily,
|
||||
fontWeight: fontFamily ? undefined : "bold",
|
||||
marginRight: 5,
|
||||
flexShrink: 0,
|
||||
...(wrapType
|
||||
? { width: fontScale(44), lineHeight: fontScale(22) }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{isWrapped ? typeName.replace(/(.{2})/g, "$1\n").trim() : typeName}
|
||||
{wrapType ? formatWrappedTrainType(typeName) : typeName}
|
||||
{fontFamily === "JR-WEST-PLUS" ? (
|
||||
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
{isOneMan && <OneManText />}
|
||||
</View>
|
||||
@@ -291,9 +300,6 @@ export const HeaderText: FC<Props> = ({
|
||||
: { fontSize: fontScale(17) }),
|
||||
flexShrink: 1,
|
||||
}}
|
||||
onTextLayout={(e) => {
|
||||
if (e.nativeEvent.lines.length > 1) setIsWrapped(true);
|
||||
}}
|
||||
>
|
||||
{trainName}
|
||||
</Text>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { lineListPair, stationIDPair } from '@/lib/getStationList';
|
||||
import { useStationList } from '@/stateBox/useStationList';
|
||||
import { OriginalStationList } from '@/lib/CommonTypes';
|
||||
|
||||
const computeThroughStations = (
|
||||
trainData: string[],
|
||||
stationList: any[][],
|
||||
originalStationList: Record<string, any[]>
|
||||
originalStationList: OriginalStationList
|
||||
): { trainDataWithThrough: string[]; haveThrough: boolean } => {
|
||||
if (!trainData.length) return { trainDataWithThrough: [], haveThrough: false };
|
||||
|
||||
|
||||
@@ -1082,7 +1082,7 @@ const TrainInfoDetail: FC<{
|
||||
uwasa,
|
||||
optional_text,
|
||||
} = data;
|
||||
const { fontAvailable } = getTrainType({
|
||||
const { fontFamily } = getTrainType({
|
||||
type: data.type,
|
||||
whiteMode: false,
|
||||
});
|
||||
@@ -1120,13 +1120,16 @@ const TrainInfoDetail: FC<{
|
||||
style={[
|
||||
styles.typeTagText,
|
||||
{
|
||||
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
|
||||
fontWeight: !fontAvailable ? "bold" : undefined,
|
||||
paddingTop: fontAvailable ? 2 : 0,
|
||||
fontFamily,
|
||||
fontWeight: fontFamily ? undefined : "bold",
|
||||
paddingTop: fontFamily ? 2 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{typeName}
|
||||
{fontFamily === "JR-WEST-PLUS" ? (
|
||||
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
|
||||
import { InfogramText } from "./ActionSheetComponents/EachTrainInfoCore/HeaderTextParts/InfogramText";
|
||||
import { resolveTrainIconEntries } from "@/lib/trainIconEntries";
|
||||
import { TrainIconStack } from "./ActionSheetComponents/EachTrainInfoCore/TrainIconStack";
|
||||
import { formatWrappedTrainType, shouldWrapTrainType } from "@/lib/trainTypeWrap";
|
||||
|
||||
export const AllTrainDiagramView: FC = () => {
|
||||
const { colors, fixed } = useThemeColors();
|
||||
@@ -107,9 +108,7 @@ export const AllTrainDiagramView: FC = () => {
|
||||
if (directions != undefined) {
|
||||
iconTrainDirection = directions ? true : false;
|
||||
}
|
||||
const [isWrapped, setIsWrapped] = useState(false);
|
||||
|
||||
const [typeString, fontAvailable, isOneMan] = getStringConfig(type, id);
|
||||
const [typeString, fontFamily, isOneMan] = getStringConfig(type, id);
|
||||
|
||||
const trainNameString = (() => {
|
||||
switch (true) {
|
||||
@@ -134,6 +133,7 @@ export const AllTrainDiagramView: FC = () => {
|
||||
return migrateTrainName(hoge + "行き");
|
||||
}
|
||||
})();
|
||||
const wrapType = shouldWrapTrainType(typeString, trainNameString);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
@@ -164,17 +164,23 @@ export const AllTrainDiagramView: FC = () => {
|
||||
>
|
||||
{typeString && (
|
||||
<Text
|
||||
numberOfLines={wrapType ? 2 : 1}
|
||||
style={{
|
||||
fontSize: 20,
|
||||
color: fixed.textOnPrimary,
|
||||
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
|
||||
fontWeight: !fontAvailable ? "bold" : undefined,
|
||||
fontFamily,
|
||||
fontWeight: fontFamily ? undefined : "bold",
|
||||
marginRight: 5,
|
||||
flexShrink: 0,
|
||||
...(wrapType
|
||||
? { width: 44, lineHeight: 22 }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{isWrapped
|
||||
? typeString.replace(/(.{2})/g, "$1\n").trim()
|
||||
: typeString}
|
||||
{wrapType ? formatWrappedTrainType(typeString) : typeString}
|
||||
{fontFamily === "JR-WEST-PLUS" ? (
|
||||
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
)}
|
||||
{isOneMan && <OneManText />}
|
||||
@@ -195,11 +201,6 @@ export const AllTrainDiagramView: FC = () => {
|
||||
color: fixed.textOnPrimary,
|
||||
flexShrink: 1,
|
||||
}}
|
||||
onTextLayout={(e) => {
|
||||
if (e.nativeEvent.lines.length > 1) {
|
||||
setIsWrapped(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{trainNameString}
|
||||
</Text>
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ type props = {
|
||||
|
||||
export const FixedStation: FC<props> = ({ stationID }) => {
|
||||
const { colors, fixed } = useThemeColors();
|
||||
const { mapSwitch } = useTrainMenu();
|
||||
const { mapSwitch, playbackCurrentTimeIso } = useTrainMenu();
|
||||
const {
|
||||
currentTrain,
|
||||
fixedPosition,
|
||||
@@ -151,11 +151,11 @@ export const FixedStation: FC<props> = ({ stationID }) => {
|
||||
if (!currentTrain) return () => {};
|
||||
const data = trainTimeAndNumber
|
||||
.filter((d) => currentTrain.map((m) => m.num).includes(d.train)) //現在の列車に絞る[ToDo]
|
||||
.filter((d) => trainTimeFiltering({ d, currentTrain, station })) //時間フィルター
|
||||
.filter((d) => trainTimeFiltering({ d, currentTrain, station, stationList, now: playbackCurrentTimeIso })) //時間フィルター
|
||||
.filter((d) => !d.isThrough)
|
||||
.filter((d) => d.lastStation != station[0].Station_JP); //最終列車表示設定
|
||||
setSelectedTrain(data);
|
||||
}, [trainTimeAndNumber, currentTrain, tick /*liveActivity periodic refresh*/]);
|
||||
}, [trainTimeAndNumber, currentTrain, stationList, playbackCurrentTimeIso, tick /*liveActivity periodic refresh*/]);
|
||||
|
||||
useEffect(() => {
|
||||
liveNotifyIdRef.current = liveNotifyId;
|
||||
@@ -259,7 +259,7 @@ export const FixedStation: FC<props> = ({ stationID }) => {
|
||||
const player = delayAnnouncementPlayerRef.current;
|
||||
setAudioModeAsync({
|
||||
playsInSilentMode: true,
|
||||
shouldPlayInBackground: 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) {
|
||||
|
||||
@@ -28,17 +28,65 @@ import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
|
||||
import { resolveTrainDataIcon } from "@/lib/trainDataIcon";
|
||||
import { resolveTrainIconEntries } from "@/lib/trainIconEntries";
|
||||
import {
|
||||
startTrainFollowActivity,
|
||||
updateTrainFollowActivity,
|
||||
endTrainFollowActivity,
|
||||
isAvailable as isLiveActivityAvailable,
|
||||
cancelLocationAnnouncements,
|
||||
} from "expo-live-activity";
|
||||
import {
|
||||
DEFAULT_BACKGROUND_RIKKA_TRIGGER_SOURCE,
|
||||
prepareBackgroundRikkaAnnouncements,
|
||||
sendTrainPositionRikkaAnnouncement,
|
||||
} from "@/lib/backgroundRikkaAnnouncements";
|
||||
import { AS } from "@/storageControl";
|
||||
import { STORAGE_KEYS } from "@/constants";
|
||||
|
||||
type props = {
|
||||
trainID: string;
|
||||
};
|
||||
|
||||
type TrainPathEntry = {
|
||||
raw: string;
|
||||
station: string;
|
||||
se: string;
|
||||
time: string;
|
||||
index: number;
|
||||
isThrough: boolean;
|
||||
hasTime: boolean;
|
||||
};
|
||||
|
||||
const normalizeTrainPosLabel = (value: string) =>
|
||||
value
|
||||
.replace(
|
||||
/(下り)|(上り)|\(下り\)|\(上り\)|(徳島線)|(高徳線)|(坂出方)|(児島方)/g,
|
||||
""
|
||||
)
|
||||
.trim();
|
||||
|
||||
const isHiddenThroughPointEntry = (raw: string) => {
|
||||
const [station = "", se = ""] = (raw || "").split(",");
|
||||
return station.startsWith(".") && se.includes("通");
|
||||
};
|
||||
|
||||
const calcDistanceMinute = (
|
||||
time: string,
|
||||
delayTime: number,
|
||||
playbackCurrentTimeIso?: string | null
|
||||
) => {
|
||||
if (!time || time === "") return null;
|
||||
const now = playbackCurrentTimeIso ? dayjs(playbackCurrentTimeIso) : dayjs();
|
||||
const hour = parseInt(time.split(":")[0], 10);
|
||||
const target = now
|
||||
.hour(hour < 4 ? hour + 24 : hour)
|
||||
.minute(parseInt(time.split(":")[1], 10));
|
||||
let diff = target.diff(now, "minute") + delayTime;
|
||||
if (now.hour() < 4 && hour < 4) diff -= 1440;
|
||||
return diff;
|
||||
};
|
||||
|
||||
export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
const { colors, fixed } = useThemeColors();
|
||||
const {
|
||||
@@ -50,19 +98,39 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
setFixedPositionSize,
|
||||
} = useCurrentTrain();
|
||||
|
||||
const { mapSwitch, iconSetting } = useTrainMenu();
|
||||
const { allCustomTrainData, allTrainDiagram } = useAllTrainDiagram();
|
||||
const { mapSwitch, iconSetting, playbackCurrentTimeIso } = useTrainMenu();
|
||||
const { allCustomTrainData, allTrainDiagram, getTodayOperationByTrainId } =
|
||||
useAllTrainDiagram();
|
||||
const iconDisplayMode = normalizeIconDisplayMode(iconSetting);
|
||||
|
||||
const [liveNotifyId, setLiveNotifyId] = useState<string | null>(null);
|
||||
const liveNotifyIdRef = useRef<string | null>(null);
|
||||
const hasStartedRef = useRef(false);
|
||||
const backgroundRikkaSignatureRef = useRef("");
|
||||
const lastTrainPositionAnnouncementRef = useRef("");
|
||||
const backgroundRikkaTrackingId = `train-${trainID}`;
|
||||
|
||||
const [train, setTrain] = useState<trainDataType>(null);
|
||||
const [customData, setCustomData] = useState<CustomTrainData>(
|
||||
getCurrentTrainData(trainID, currentTrain, allCustomTrainData)
|
||||
);
|
||||
const customTrainIcon = resolveTrainDataIcon(customData, iconDisplayMode);
|
||||
const todayOperation = useMemo(
|
||||
() => (getTodayOperationByTrainId(trainID) ?? []).filter((d) => d.state !== 100),
|
||||
[getTodayOperationByTrainId, trainID]
|
||||
);
|
||||
const customTrainIcon = useMemo(() => {
|
||||
const iconEntry = resolveTrainIconEntries({
|
||||
trainNum: trainID,
|
||||
customTrainData: customData,
|
||||
todayOperation,
|
||||
iconDisplayMode,
|
||||
})[0];
|
||||
|
||||
return (
|
||||
iconEntry?.vehicle_info_img ||
|
||||
resolveTrainDataIcon(customData, iconDisplayMode)
|
||||
);
|
||||
}, [customData, iconDisplayMode, todayOperation, trainID]);
|
||||
useEffect(() => {
|
||||
setCustomData(
|
||||
getCurrentTrainData(trainID, currentTrain, allCustomTrainData)
|
||||
@@ -89,8 +157,11 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
);
|
||||
|
||||
const computedTrainDataWithThrough = useMemo(() => {
|
||||
const trainData = allTrainDiagram[trainID]?.split("#");
|
||||
if (!trainData) return [];
|
||||
const trainData =
|
||||
allTrainDiagram[trainID]
|
||||
?.split("#")
|
||||
.filter((entry) => !isHiddenThroughPointEntry(entry)) ?? [];
|
||||
if (trainData.length === 0) return [];
|
||||
|
||||
// 駅名ごとに駅情報を集約するマップを構築
|
||||
const stationByNameMap = new Map();
|
||||
@@ -136,6 +207,8 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
originalStationList[
|
||||
lineListPair[stationIDPair[betweenStationLine]]
|
||||
].forEach((d) => {
|
||||
const isHiddenPassPoint = d.Station_JP?.startsWith(".");
|
||||
if (isHiddenPassPoint) return;
|
||||
if (
|
||||
d.StationNumber > baseStationNumberFirst &&
|
||||
d.StationNumber < baseStationNumberSecond
|
||||
@@ -193,6 +266,22 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
setStopStationList(computedStopStationIDList);
|
||||
}, [computedStopStationIDList]);
|
||||
const [currentPosition, setCurrentPosition] = useState<string[]>([]);
|
||||
const parsedTrainPath = useMemo<TrainPathEntry[]>(
|
||||
() =>
|
||||
trainDataWidhThrough.map((raw, index) => {
|
||||
const [station = "", se = "", time = ""] = (raw || "").split(",");
|
||||
return {
|
||||
raw,
|
||||
station,
|
||||
se,
|
||||
time,
|
||||
index,
|
||||
isThrough: se.includes("通"),
|
||||
hasTime: time !== "",
|
||||
};
|
||||
}),
|
||||
[trainDataWidhThrough]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let position = getPosition(train);
|
||||
@@ -227,96 +316,127 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
}
|
||||
}, [train, stopStationIDList]);
|
||||
|
||||
const [nextStationData, setNextStationData] = useState<StationProps[]>([]);
|
||||
const [untilStationData, setUntilStationData] = useState<StationProps[]>([]);
|
||||
const [currentPointData, setCurrentPointData] = useState<StationProps[]>([]);
|
||||
const [nextStopStationData, setNextStopStationData] = useState<StationProps[]>([]);
|
||||
const [untilStationData, setUntilStationData] = useState<string[]>([]);
|
||||
const [probably, setProbably] = useState(false);
|
||||
const [isCurrentPointDisplay, setIsCurrentPointDisplay] = useState(false);
|
||||
const [currentDisplayIndex, setCurrentDisplayIndex] = useState(0);
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (playbackCurrentTimeIso) return;
|
||||
const interval = setInterval(() => setTick((value) => value + 1), 15000);
|
||||
return () => clearInterval(interval);
|
||||
}, [playbackCurrentTimeIso]);
|
||||
|
||||
useEffect(() => {
|
||||
//棒線駅判定を入れて、棒線駅なら時間を見て分数がマイナスならcontinue;
|
||||
const points = findReversalPoints(currentPosition, stopStationIDList);
|
||||
if (!points) return;
|
||||
if (points.length == 0) return;
|
||||
let searchCountFirst = points.findIndex((d) => d == true);
|
||||
let searchCountLast = points.findLastIndex((d) => d == true);
|
||||
if (!points || points.length === 0) return;
|
||||
|
||||
const delayTime = train?.delay == "入線" ? 0 : train?.delay;
|
||||
let additionalSkipCount = 0;
|
||||
const pointIndexes = points.reduce(
|
||||
(acc: number[], isCurrent, index) => {
|
||||
if (isCurrent) acc.push(index);
|
||||
return acc;
|
||||
},
|
||||
[] as number[]
|
||||
);
|
||||
if (!pointIndexes.length) return;
|
||||
|
||||
// 2駅間走行中の場合: ダイヤ順で後ろのインデックスが進行方向の駅
|
||||
// Direction に関係なく、travel-order で大きい方が「向かう駅」
|
||||
let searchStart: number;
|
||||
if (currentPosition.length === 2) {
|
||||
const idx0 = stopStationIDList.findIndex(d => d.includes(currentPosition[0]));
|
||||
const idx1 = stopStationIDList.findIndex(d => d.includes(currentPosition[1]));
|
||||
const aheadIdx = Math.max(
|
||||
idx0 >= 0 ? idx0 : -1,
|
||||
idx1 >= 0 ? idx1 : -1
|
||||
);
|
||||
searchStart = aheadIdx >= 0 ? aheadIdx : searchCountLast;
|
||||
} else {
|
||||
searchStart = searchCountFirst;
|
||||
}
|
||||
const firstMatchedIndex = pointIndexes[0];
|
||||
const isCurrentStationCluster = currentPosition.length <= 1;
|
||||
const delayTime =
|
||||
train?.delay == "入線" ? 0 : parseInt(String(train?.delay), 10) || 0;
|
||||
|
||||
for (
|
||||
let searchCount = searchStart;
|
||||
searchCount < trainDataWidhThrough.length;
|
||||
searchCount++
|
||||
) {
|
||||
const nextPos = trainDataWidhThrough[searchCount];
|
||||
let anchorIndex = firstMatchedIndex;
|
||||
let usedTimeEstimation = false;
|
||||
const shouldShowCurrentPoint = isCurrentStationCluster;
|
||||
const hasIntermediateCurrentPoints = pointIndexes.length > 1;
|
||||
|
||||
if (nextPos) {
|
||||
const [station, se, time] = nextPos.split(",");
|
||||
if (!isCurrentStationCluster && hasIntermediateCurrentPoints) {
|
||||
let upcomingTimedIndex = -1;
|
||||
let lastPassedTimedIndex = -1;
|
||||
|
||||
// 通過駅はスキップ
|
||||
if (se.includes("通")) {
|
||||
for (
|
||||
let searchCount = firstMatchedIndex;
|
||||
searchCount < parsedTrainPath.length;
|
||||
searchCount++
|
||||
) {
|
||||
const entry = parsedTrainPath[searchCount];
|
||||
if (!entry?.raw || !entry.hasTime) continue;
|
||||
|
||||
const distanceMinute = calcDistanceMinute(
|
||||
entry.time,
|
||||
delayTime,
|
||||
playbackCurrentTimeIso
|
||||
);
|
||||
if (distanceMinute == null) continue;
|
||||
|
||||
if (distanceMinute < 0) {
|
||||
lastPassedTimedIndex = searchCount;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 駅に停車中(1点一致)の場合は時刻判定不要
|
||||
if (searchCountFirst == searchCountLast) {
|
||||
setNextStationData(getStationDataFromName(station));
|
||||
break;
|
||||
}
|
||||
|
||||
// 2駅間走行中: 時刻で既に通過済みか判定
|
||||
let distanceMinute = 0;
|
||||
if (time != "") {
|
||||
const now = dayjs();
|
||||
const hour = parseInt(time.split(":")[0]);
|
||||
const distanceTime = now
|
||||
.hour(hour < 4 ? hour + 24 : hour)
|
||||
.minute(parseInt(time.split(":")[1]));
|
||||
distanceMinute = distanceTime.diff(now, "minute") + delayTime;
|
||||
if (now.hour() < 4) {
|
||||
if (hour < 4) {
|
||||
distanceMinute = distanceMinute - 1440;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (distanceMinute >= 0) {
|
||||
setNextStationData(getStationDataFromName(station));
|
||||
break;
|
||||
} else {
|
||||
additionalSkipCount++;
|
||||
continue;
|
||||
}
|
||||
upcomingTimedIndex = searchCount;
|
||||
break;
|
||||
}
|
||||
|
||||
const baseIndex =
|
||||
lastPassedTimedIndex >= 0 ? lastPassedTimedIndex + 1 : firstMatchedIndex;
|
||||
|
||||
if (upcomingTimedIndex >= 0) {
|
||||
const hasUntimedGapBeforeUpcoming = parsedTrainPath
|
||||
.slice(baseIndex, upcomingTimedIndex)
|
||||
.some((entry) => entry?.raw && !entry.hasTime);
|
||||
|
||||
anchorIndex = hasUntimedGapBeforeUpcoming ? baseIndex : upcomingTimedIndex;
|
||||
} else if (lastPassedTimedIndex >= 0) {
|
||||
const lastPassedEntry = parsedTrainPath[lastPassedTimedIndex];
|
||||
anchorIndex = lastPassedEntry?.isThrough
|
||||
? Math.min(lastPassedTimedIndex + 1, parsedTrainPath.length - 1)
|
||||
: lastPassedTimedIndex;
|
||||
}
|
||||
|
||||
usedTimeEstimation = anchorIndex !== firstMatchedIndex;
|
||||
}
|
||||
let trainList = [];
|
||||
for (
|
||||
let searchCount = searchCountFirst - 1;
|
||||
searchCount < points.length;
|
||||
searchCount++
|
||||
) {
|
||||
trainList.push(trainDataWidhThrough[searchCount]);
|
||||
}
|
||||
if (additionalSkipCount > 0) {
|
||||
trainList = trainList.slice(additionalSkipCount);
|
||||
setProbably(true);
|
||||
} else {
|
||||
setProbably(false);
|
||||
}
|
||||
|
||||
const anchorEntry = parsedTrainPath[anchorIndex];
|
||||
const currentPointName = anchorEntry?.station || "";
|
||||
setCurrentPointData(
|
||||
currentPointName ? getStationDataFromName(currentPointName) : []
|
||||
);
|
||||
|
||||
const nextStopSearchStart = shouldShowCurrentPoint
|
||||
? anchorIndex + 1
|
||||
: anchorIndex;
|
||||
const nextStopEntry = parsedTrainPath.find(
|
||||
(entry, index) =>
|
||||
index >= nextStopSearchStart && !!entry?.station && !entry.isThrough
|
||||
);
|
||||
const nextStopName = nextStopEntry?.station || "";
|
||||
setNextStopStationData(
|
||||
nextStopName ? getStationDataFromName(nextStopName) : []
|
||||
);
|
||||
|
||||
const visibleStart = Math.max(anchorIndex - 1, 0);
|
||||
const trainList = parsedTrainPath
|
||||
.slice(visibleStart)
|
||||
.map((entry) => entry.raw)
|
||||
.filter((entry) => !!entry);
|
||||
|
||||
setProbably(usedTimeEstimation);
|
||||
setIsCurrentPointDisplay(shouldShowCurrentPoint);
|
||||
setCurrentDisplayIndex(Math.max(anchorIndex - visibleStart, 0));
|
||||
setUntilStationData(trainList);
|
||||
}, [currentPosition, trainDataWidhThrough]);
|
||||
}, [
|
||||
currentPosition,
|
||||
parsedTrainPath,
|
||||
playbackCurrentTimeIso,
|
||||
stopStationIDList,
|
||||
tick,
|
||||
train?.delay,
|
||||
getStationDataFromName,
|
||||
]);
|
||||
const [ToData, setToData] = useState("");
|
||||
useEffect(() => {
|
||||
if (customData.to_data && customData.to_data != "") {
|
||||
@@ -335,7 +455,9 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
setStation(data);
|
||||
}, [ToData]);
|
||||
const lineColor =
|
||||
station.length > 0
|
||||
customData.to_data_color && customData.to_data_color.length > 0
|
||||
? customData.to_data_color[0]
|
||||
: station.length > 0
|
||||
? lineColorList[station[0]?.StationNumber?.slice(0, 1)]
|
||||
: "black";
|
||||
//const lineColor = "red";
|
||||
@@ -355,7 +477,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
(last: number, d: string, i: number) => (d ? i : last), -1
|
||||
);
|
||||
const filteredTrainData = trainDataWidhThrough.filter((d, idx) => {
|
||||
if (!d) return false;
|
||||
if (!d || isHiddenThroughPointEntry(d)) return false;
|
||||
const [, se] = d.split(",");
|
||||
if (!se) return true;
|
||||
// 着を含み発を含まないエントリは終着駅のみ許可
|
||||
@@ -407,13 +529,22 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
};
|
||||
});
|
||||
|
||||
const shouldShowCurrentLabel =
|
||||
isCurrentPointDisplay || currentPointData[0]?.Station_JP === train?.Pos;
|
||||
const currentStationLabel = shouldShowCurrentLabel
|
||||
? currentPointData[0]?.Station_JP || train?.Pos || ""
|
||||
: train?.Pos || "";
|
||||
const bannerStationData = shouldShowCurrentLabel
|
||||
? currentPointData
|
||||
: nextStopStationData;
|
||||
|
||||
// 全駅リスト中の現在地インデックス
|
||||
const currentStationIndex = (() => {
|
||||
const pos = train?.Pos || "";
|
||||
const pos = currentStationLabel;
|
||||
if (!pos) return 0;
|
||||
// Pos は "駅名" (駅にいる時) or "駅A~駅B" (走行中) の形式
|
||||
const posStations = pos.split("~").map((s: string) =>
|
||||
s.replace(/(下り)|(上り)|\(下り\)|\(上り\)/g, "").trim()
|
||||
normalizeTrainPosLabel(s)
|
||||
);
|
||||
// 完全一致
|
||||
const firstIdx = allStations.findIndex((s) => s.name === posStations[0]);
|
||||
@@ -427,7 +558,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
})();
|
||||
|
||||
const nextStationIndex = (() => {
|
||||
const name = nextStationData[0]?.Station_JP;
|
||||
const name = nextStopStationData[0]?.Station_JP;
|
||||
if (!name) return -1;
|
||||
const idx = stationStops.indexOf(name);
|
||||
if (idx >= 0) return idx;
|
||||
@@ -452,11 +583,10 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
if (!liveNotifyId || !train) return;
|
||||
const delayNum = train.delay === "入線" ? 0 : parseInt(String(train.delay)) || 0;
|
||||
const delayStatus = delayNum > 0 ? `${delayNum}分遅れ` : "定刻";
|
||||
const positionStatus =
|
||||
nextStationData[0]?.Station_JP === train.Pos ? "ただいま" : "次は";
|
||||
const positionStatus = shouldShowCurrentLabel ? "ただいま" : "次は";
|
||||
updateTrainFollowActivity(liveNotifyId, {
|
||||
currentStation: train.Pos || "",
|
||||
nextStation: nextStationData[0]?.Station_JP || "",
|
||||
currentStation: currentStationLabel,
|
||||
nextStation: nextStopStationData[0]?.Station_JP || "",
|
||||
delayMinutes: delayNum,
|
||||
scheduledArrival: "",
|
||||
trainNumber: trainID,
|
||||
@@ -472,13 +602,27 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
allStations,
|
||||
currentStationIndex,
|
||||
}).catch(() => {});
|
||||
}, [train, nextStationData, liveNotifyId, stationStops, nextStationIndex, currentStationIndex]);
|
||||
}, [
|
||||
train,
|
||||
nextStopStationData,
|
||||
liveNotifyId,
|
||||
stationStops,
|
||||
nextStationIndex,
|
||||
currentStationIndex,
|
||||
currentStationLabel,
|
||||
shouldShowCurrentLabel,
|
||||
]);
|
||||
|
||||
// バナー表示と同時に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) {
|
||||
@@ -489,15 +633,14 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
}
|
||||
const delayNum = train?.delay === "入線" ? 0 : parseInt(String(train?.delay)) || 0;
|
||||
const delayStatus = delayNum > 0 ? `${delayNum}分遅れ` : "定刻";
|
||||
const positionStatus =
|
||||
nextStationData[0]?.Station_JP === train?.Pos ? "ただいま" : "次は";
|
||||
const positionStatus = shouldShowCurrentLabel ? "ただいま" : "次は";
|
||||
try {
|
||||
const id = await startTrainFollowActivity({
|
||||
trainNumber: trainID,
|
||||
lineName: "",
|
||||
destination: ToData,
|
||||
currentStation: train?.Pos || "",
|
||||
nextStation: nextStationData[0]?.Station_JP || "",
|
||||
currentStation: currentStationLabel,
|
||||
nextStation: nextStopStationData[0]?.Station_JP || "",
|
||||
delayMinutes: delayNum,
|
||||
scheduledArrival: "",
|
||||
trainType: customTrainType.shortName,
|
||||
@@ -514,10 +657,175 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
setLiveNotifyId(id);
|
||||
} catch (e) {
|
||||
console.warn('[LiveNotify] start error:', e);
|
||||
hasStartedRef.current = false;
|
||||
}
|
||||
};
|
||||
startActivity();
|
||||
}, [train]);
|
||||
}, [
|
||||
train,
|
||||
nextStopStationData,
|
||||
currentStationLabel,
|
||||
shouldShowCurrentLabel,
|
||||
ToData,
|
||||
trainID,
|
||||
customTrainType.shortName,
|
||||
trainNameText,
|
||||
customTrainType.color,
|
||||
lineColor,
|
||||
stationStops,
|
||||
nextStationIndex,
|
||||
allStations,
|
||||
currentStationIndex,
|
||||
]);
|
||||
|
||||
// iOSへ残り停車駅の位置トリガーとりっかちゃん通知音を登録する。
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "ios" || playbackCurrentTimeIso) return;
|
||||
|
||||
const upcomingStations = allStations
|
||||
.slice(currentStationIndex + 1)
|
||||
.map((entry, offset) => ({ entry, offset }))
|
||||
.filter(({ entry }) => entry.isStop)
|
||||
.map(({ entry, offset }) => {
|
||||
const stationData = getStationDataFromName(entry.name)[0];
|
||||
if (
|
||||
!stationData ||
|
||||
!Number.isFinite(stationData.lat) ||
|
||||
!Number.isFinite(stationData.lng)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
identifier: `${currentStationIndex + 1 + offset}-${stationData.StationNumber || entry.name}`,
|
||||
stationName: entry.name,
|
||||
latitude: stationData.lat,
|
||||
longitude: stationData.lng,
|
||||
};
|
||||
})
|
||||
.filter((station): station is NonNullable<typeof station> => station != null)
|
||||
.slice(0, 20);
|
||||
|
||||
if (upcomingStations.length === 0) return;
|
||||
|
||||
const signature = [
|
||||
backgroundRikkaTrackingId,
|
||||
currentStationIndex,
|
||||
...upcomingStations.map((station) => station.identifier),
|
||||
].join(":");
|
||||
if (backgroundRikkaSignatureRef.current === signature) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
Promise.all([
|
||||
AS.getItem(STORAGE_KEYS.BACKGROUND_RIKKA_ANNOUNCEMENT).catch(() => "false"),
|
||||
AS.getItem(STORAGE_KEYS.BACKGROUND_RIKKA_TRIGGER_SOURCE).catch(
|
||||
() => DEFAULT_BACKGROUND_RIKKA_TRIGGER_SOURCE
|
||||
),
|
||||
])
|
||||
.then(async ([value, source]) => {
|
||||
const enabled = value === true || value === "true";
|
||||
if (
|
||||
!enabled ||
|
||||
source === "trainPosition" ||
|
||||
controller.signal.aborted
|
||||
) {
|
||||
backgroundRikkaSignatureRef.current = "";
|
||||
await cancelLocationAnnouncements(backgroundRikkaTrackingId);
|
||||
return;
|
||||
}
|
||||
|
||||
backgroundRikkaSignatureRef.current = signature;
|
||||
const result = await prepareBackgroundRikkaAnnouncements({
|
||||
trackingId: backgroundRikkaTrackingId,
|
||||
stations: upcomingStations,
|
||||
signal: controller.signal,
|
||||
});
|
||||
console.info(
|
||||
`[BackgroundRikka] scheduled=${result.scheduled} failed=${result.failedStations.length}`
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
backgroundRikkaSignatureRef.current = "";
|
||||
if (!controller.signal.aborted) {
|
||||
console.warn("[BackgroundRikka] Failed to schedule announcements", error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [
|
||||
allStations,
|
||||
backgroundRikkaTrackingId,
|
||||
currentStationIndex,
|
||||
getStationDataFromName,
|
||||
playbackCurrentTimeIso,
|
||||
]);
|
||||
|
||||
// 検証用: 列車走行位置から算出した次駅が変わった瞬間に通知する。
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "ios") return;
|
||||
|
||||
const stationName = nextStopStationData[0]?.Station_JP;
|
||||
if (!stationName) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
Promise.all([
|
||||
AS.getItem(STORAGE_KEYS.BACKGROUND_RIKKA_ANNOUNCEMENT).catch(() => "false"),
|
||||
AS.getItem(STORAGE_KEYS.BACKGROUND_RIKKA_TRIGGER_SOURCE).catch(
|
||||
() => DEFAULT_BACKGROUND_RIKKA_TRIGGER_SOURCE
|
||||
),
|
||||
])
|
||||
.then(async ([enabledValue, source]) => {
|
||||
const enabled =
|
||||
enabledValue === true || enabledValue === "true";
|
||||
if (
|
||||
!enabled ||
|
||||
source !== "trainPosition" ||
|
||||
controller.signal.aborted
|
||||
) {
|
||||
lastTrainPositionAnnouncementRef.current = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const announcementKey = `${trainID}:${stationName}`;
|
||||
if (
|
||||
lastTrainPositionAnnouncementRef.current === announcementKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastTrainPositionAnnouncementRef.current = announcementKey;
|
||||
await cancelLocationAnnouncements(backgroundRikkaTrackingId);
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
await sendTrainPositionRikkaAnnouncement({
|
||||
stationName,
|
||||
trainId: trainID,
|
||||
signal: controller.signal,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
lastTrainPositionAnnouncementRef.current = "";
|
||||
if (!controller.signal.aborted) {
|
||||
console.warn(
|
||||
"[BackgroundRikka] Train-position announcement failed",
|
||||
error
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [
|
||||
backgroundRikkaTrackingId,
|
||||
nextStopStationData,
|
||||
train?.Pos,
|
||||
trainID,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelLocationAnnouncements(backgroundRikkaTrackingId).catch(() => {});
|
||||
};
|
||||
}, [backgroundRikkaTrackingId]);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -576,19 +884,18 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
<Text
|
||||
style={{
|
||||
fontSize: trainNameText.length > 4 ? 12 : 14,
|
||||
fontFamily: customTrainType.fontAvailable
|
||||
? "JR-Nishi"
|
||||
: undefined,
|
||||
fontWeight: !customTrainType.fontAvailable
|
||||
? "bold"
|
||||
: undefined,
|
||||
marginTop: customTrainType.fontAvailable ? 3 : 0,
|
||||
fontFamily: customTrainType.fontFamily,
|
||||
fontWeight: customTrainType.fontFamily ? undefined : "bold",
|
||||
marginTop: customTrainType.fontFamily ? 3 : 0,
|
||||
color: fixed.textOnPrimary,
|
||||
textAlignVertical: "center",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{customTrainType.shortName}
|
||||
{customTrainType.fontFamily === "JR-WEST-PLUS" ? (
|
||||
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
{customData.train_name && (
|
||||
<Text
|
||||
@@ -693,9 +1000,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
marginVertical: -1,
|
||||
}}
|
||||
>
|
||||
{nextStationData[0]?.Station_JP == train?.Pos
|
||||
? "ただいま"
|
||||
: "次は"}
|
||||
{shouldShowCurrentLabel ? "ただいま" : "次は"}
|
||||
</Text>
|
||||
{probably && (
|
||||
<Text
|
||||
@@ -713,7 +1018,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
)}
|
||||
</View>
|
||||
<StationNumberMaker
|
||||
currentStation={nextStationData}
|
||||
currentStation={bannerStationData}
|
||||
singleSize={20}
|
||||
useEach={true}
|
||||
/>
|
||||
@@ -725,7 +1030,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{nextStationData[0]?.Station_JP || "不明"}
|
||||
{bannerStationData[0]?.Station_JP || "不明"}
|
||||
</Text>
|
||||
{fixedPositionSize !== 226 && (
|
||||
<View
|
||||
@@ -749,6 +1054,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
train={train}
|
||||
lineColor={lineColor}
|
||||
trainDataWithThrough={untilStationData}
|
||||
currentDisplayIndex={currentDisplayIndex}
|
||||
isSmall={fixedPositionSize !== 226}
|
||||
/>
|
||||
</View>
|
||||
@@ -875,6 +1181,7 @@ const CurrentPositionBox = ({
|
||||
train,
|
||||
lineColor,
|
||||
trainDataWithThrough,
|
||||
currentDisplayIndex,
|
||||
isSmall,
|
||||
}) => {
|
||||
const { colors } = useThemeColors();
|
||||
@@ -884,13 +1191,13 @@ const CurrentPositionBox = ({
|
||||
const { isBetween, Pos: PosData } = trainPosition(train);
|
||||
if (isBetween === true) {
|
||||
const { from, to } = PosData;
|
||||
firstText = from;
|
||||
secondText = to;
|
||||
firstText = normalizeTrainPosLabel(from);
|
||||
secondText = normalizeTrainPosLabel(to);
|
||||
marginText = "→";
|
||||
} else {
|
||||
const { Pos } = PosData;
|
||||
if (Pos !== "") {
|
||||
firstText = Pos;
|
||||
firstText = normalizeTrainPosLabel(Pos);
|
||||
}
|
||||
}
|
||||
const delayTime = train?.delay == "入線" ? 0 : parseInt(train?.delay);
|
||||
@@ -943,6 +1250,7 @@ const CurrentPositionBox = ({
|
||||
(() => {
|
||||
// 着→発ペアを同一駅で統合(EachStopListと同様)
|
||||
const merged: { d: string; arrivalTime: string | null }[] = [];
|
||||
let mergedCurrentDisplayIndex = Math.max(currentDisplayIndex, 0);
|
||||
for (let i = 0; i < trainDataWithThrough.length; i++) {
|
||||
const d = trainDataWithThrough[i];
|
||||
if (!d) continue;
|
||||
@@ -963,11 +1271,17 @@ const CurrentPositionBox = ({
|
||||
const [prevSt, prevSe, prevTime] = prev.split(",");
|
||||
if (prevSt === st && prevSe?.includes("着")) {
|
||||
merged.push({ d, arrivalTime: prevTime });
|
||||
if (i === currentDisplayIndex || i - 1 === currentDisplayIndex) {
|
||||
mergedCurrentDisplayIndex = merged.length - 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
merged.push({ d, arrivalTime: null });
|
||||
if (i === currentDisplayIndex) {
|
||||
mergedCurrentDisplayIndex = merged.length - 1;
|
||||
}
|
||||
}
|
||||
return merged.map(({ d, arrivalTime }, index) => (
|
||||
<EachStopData
|
||||
@@ -977,6 +1291,7 @@ const CurrentPositionBox = ({
|
||||
delayTime={delayTime}
|
||||
isSmall={isSmall}
|
||||
secondText={secondText}
|
||||
currentDisplayIndex={mergedCurrentDisplayIndex}
|
||||
arrivalTime={arrivalTime}
|
||||
/>
|
||||
));
|
||||
@@ -992,18 +1307,28 @@ type eachStopType = {
|
||||
isSmall: boolean;
|
||||
index: number;
|
||||
secondText: string;
|
||||
currentDisplayIndex: number;
|
||||
arrivalTime?: string | null;
|
||||
};
|
||||
|
||||
const EachStopData: FC<eachStopType> = (props) => {
|
||||
const { colors } = useThemeColors();
|
||||
const { d, delayTime, isSmall, index, secondText, arrivalTime } = props;
|
||||
const { playbackCurrentTimeIso } = useTrainMenu();
|
||||
const {
|
||||
d,
|
||||
delayTime,
|
||||
isSmall,
|
||||
index,
|
||||
secondText,
|
||||
currentDisplayIndex,
|
||||
arrivalTime,
|
||||
} = props;
|
||||
if (!d) return null;
|
||||
if (d == "") return null;
|
||||
const [station, se, time] = d.split(",");
|
||||
const calcMinute = (t: string) => {
|
||||
if (!t || t === "") return null;
|
||||
const now = dayjs();
|
||||
const now = playbackCurrentTimeIso ? dayjs(playbackCurrentTimeIso) : dayjs();
|
||||
const hour = parseInt(t.split(":")[0]);
|
||||
const dt = now
|
||||
.hour(hour < 4 ? hour + 24 : hour)
|
||||
@@ -1078,7 +1403,7 @@ const EachStopData: FC<eachStopType> = (props) => {
|
||||
style={{
|
||||
fontSize: isSmall ? 8 : 14,
|
||||
color:
|
||||
index == 1 && secondText == ""
|
||||
index === currentDisplayIndex && secondText === ""
|
||||
? "#ffe852ff"
|
||||
: se.includes("通")
|
||||
? "#020202ff"
|
||||
@@ -1088,14 +1413,14 @@ const EachStopData: FC<eachStopType> = (props) => {
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{index == 1 && secondText == ""
|
||||
{index === currentDisplayIndex && secondText === ""
|
||||
? "→"
|
||||
: se.includes("通")
|
||||
? null
|
||||
: "●"}
|
||||
</Text>
|
||||
</View>
|
||||
{index == 0 && secondText != "" && (
|
||||
{index === 0 && secondText !== "" && (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "column",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
|
||||
import { useStationList } from "@/stateBox/useStationList";
|
||||
import { OriginalStationList } from "@/lib/CommonTypes";
|
||||
|
||||
interface StationIDPair {
|
||||
[key: string]: string;
|
||||
@@ -10,12 +11,10 @@ interface LineListPair {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface OriginalStationList {
|
||||
[key: string]: Array<{
|
||||
Station_JP: string;
|
||||
StationNumber: string;
|
||||
}>;
|
||||
}
|
||||
const isHiddenThroughPointEntry = (raw: string) => {
|
||||
const [station = "", se = ""] = (raw || "").split(",");
|
||||
return station.startsWith(".") && se.includes("通");
|
||||
};
|
||||
|
||||
/**
|
||||
* 通過駅を含む列車データを生成するカスタムフック
|
||||
@@ -33,8 +32,11 @@ export const useTrainDataWithThrough = (
|
||||
const [trainDataWithThrough, setTrainDataWithThrough] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const trainData = allTrainDiagram[trainID]?.split("#");
|
||||
if (!trainData) return;
|
||||
const trainData =
|
||||
allTrainDiagram[trainID]
|
||||
?.split("#")
|
||||
.filter((entry) => !isHiddenThroughPointEntry(entry)) ?? [];
|
||||
if (trainData.length === 0) return;
|
||||
|
||||
// 各停車駅の情報を取得
|
||||
const stopStationList = trainData.map((i) => {
|
||||
@@ -76,6 +78,8 @@ export const useTrainDataWithThrough = (
|
||||
if (!lineStations) return [];
|
||||
|
||||
lineStations.forEach((d) => {
|
||||
const isHiddenPassPoint = d.Station_JP?.startsWith(".");
|
||||
if (isHiddenPassPoint) return;
|
||||
// 順方向の判定
|
||||
if (
|
||||
d.StationNumber > baseStationNumberFirst &&
|
||||
|
||||
@@ -55,7 +55,7 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
|
||||
data,
|
||||
});
|
||||
};
|
||||
const { remountKey, processHandlers, pingHandlers, webViewRef } = useWebViewRemount({
|
||||
const { remountKey, remount, processHandlers, pingHandlers, webViewRef } = useWebViewRemount({
|
||||
pingEnabled: Platform.OS === "ios",
|
||||
backgroundThresholdMs: null,
|
||||
isFocused,
|
||||
@@ -83,6 +83,20 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
|
||||
const loadEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const focusedRef = useRef(isFocused);
|
||||
const initialLoadReadyNotifiedRef = useRef(false);
|
||||
const previousMockApiFeatureEnabledRef = useRef(mockApiFeatureEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
const previousEnabled = previousMockApiFeatureEnabledRef.current;
|
||||
if (previousEnabled === mockApiFeatureEnabled) return;
|
||||
|
||||
previousMockApiFeatureEnabledRef.current = mockApiFeatureEnabled;
|
||||
addWebViewBreadcrumb("mock mode changed; remounting webview", {
|
||||
previousEnabled,
|
||||
enabled: mockApiFeatureEnabled,
|
||||
focused: focusedRef.current,
|
||||
});
|
||||
remount();
|
||||
}, [mockApiFeatureEnabled, remount]);
|
||||
|
||||
useEffect(() => {
|
||||
focusedRef.current = isFocused;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -59,7 +59,7 @@ export const ListViewItem: FC<{
|
||||
const [
|
||||
typeString,
|
||||
trainName,
|
||||
fontAvailable,
|
||||
fontFamily,
|
||||
isOneMan,
|
||||
infogram,
|
||||
priority,
|
||||
@@ -79,7 +79,7 @@ export const ListViewItem: FC<{
|
||||
to_data_color,
|
||||
train_number_override,
|
||||
} = customTrainDataDetector(d.trainNumber, allCustomTrainData);
|
||||
const [typeString, fontAvailable, isOneMan] = getStringConfig(
|
||||
const [typeString, fontFamily, isOneMan] = getStringConfig(
|
||||
type,
|
||||
d.trainNumber
|
||||
);
|
||||
@@ -111,7 +111,7 @@ export const ListViewItem: FC<{
|
||||
return [
|
||||
typeString,
|
||||
displayName,
|
||||
fontAvailable,
|
||||
fontFamily,
|
||||
isOneMan,
|
||||
infogram,
|
||||
priority,
|
||||
@@ -128,7 +128,7 @@ export const ListViewItem: FC<{
|
||||
return [
|
||||
typeString,
|
||||
"",
|
||||
fontAvailable,
|
||||
fontFamily,
|
||||
isOneMan,
|
||||
infogram,
|
||||
priority,
|
||||
@@ -145,7 +145,7 @@ export const ListViewItem: FC<{
|
||||
migrateTrainName(
|
||||
trainDataArray[trainDataArray.length - 1].split(",")[0] + "行き"
|
||||
),
|
||||
fontAvailable,
|
||||
fontFamily,
|
||||
isOneMan,
|
||||
infogram,
|
||||
priority,
|
||||
@@ -314,15 +314,18 @@ export const ListViewItem: FC<{
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
|
||||
fontWeight: !fontAvailable ? "bold" : undefined,
|
||||
paddingTop: fontAvailable ? 2 : 0,
|
||||
fontFamily,
|
||||
fontWeight: fontFamily ? undefined : "bold",
|
||||
paddingTop: fontFamily ? 2 : 0,
|
||||
paddingLeft: 10,
|
||||
color: isCancelled ? "gray" : color,
|
||||
textDecorationLine: isCancelled ? "line-through" : "none",
|
||||
}}
|
||||
>
|
||||
{typeString}
|
||||
{fontFamily === "JR-WEST-PLUS" ? (
|
||||
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
{showVehicle
|
||||
? (() => {
|
||||
|
||||
@@ -1,225 +1,9 @@
|
||||
import React, { useRef, useState, useEffect, useLayoutEffect } from "react";
|
||||
import { View, Text, TouchableOpacity, Linking } from "react-native";
|
||||
//import MapView from "react-native-maps";
|
||||
import { useCurrentTrain } from "../stateBox/useCurrentTrain";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import lineColorList from "../assets/originData/lineColorList";
|
||||
import { lineListPair, stationIDPair } from "../lib/getStationList";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import { useTrainMenu } from "../stateBox/useTrainMenu";
|
||||
//import { MapPin } from "./TrainMenu/MapPin";
|
||||
import { UsefulBox } from "./TrainMenu/UsefulBox";
|
||||
import { MapsButton } from "./TrainMenu/MapsButton";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
export default function TrainMenu({ style }) {
|
||||
const { fixed } = useThemeColors();
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
|
||||
type TrainMenuProps = {
|
||||
style?: StyleProp<ViewStyle>;
|
||||
};
|
||||
|
||||
export default function TrainMenu(_props: TrainMenuProps) {
|
||||
return null;
|
||||
const { webview } = useCurrentTrain();
|
||||
const mapRef = useRef();
|
||||
const { navigate, goBack } = useNavigation();
|
||||
const [stationPin, setStationPin] = useState([]);
|
||||
const {
|
||||
selectedLine,
|
||||
setSelectedLine,
|
||||
mapsStationData: stationData,
|
||||
} = useTrainMenu();
|
||||
useEffect(() => {
|
||||
const stationPinData = [];
|
||||
Object.keys(stationData).forEach((d, indexBase) => {
|
||||
stationData[d].forEach((D, index) => {
|
||||
if (!D.StationMap) return null;
|
||||
if (selectedLine && selectedLine != d) return;
|
||||
const latlng = D.StationMap.replace(
|
||||
"https://www.google.co.jp/maps/place/",
|
||||
""
|
||||
).split(",");
|
||||
if (latlng.length == 0) return null;
|
||||
stationPinData.push({ D, d, latlng, indexBase: 0, index });
|
||||
});
|
||||
});
|
||||
setStationPin(stationPinData);
|
||||
}, [stationData, selectedLine]);
|
||||
useLayoutEffect(() => {
|
||||
mapRef.current.fitToCoordinates(
|
||||
stationPin.map(({ latlng }) => ({
|
||||
latitude: parseFloat(latlng[0]),
|
||||
longitude: parseFloat(latlng[1]),
|
||||
})),
|
||||
{ edgePadding: { top: 80, bottom: 120, left: 50, right: 50 } } // Add margin values here
|
||||
);
|
||||
}, [stationPin]);
|
||||
return (
|
||||
<View style={{ height: "100%", backgroundColor: fixed.primary, ...style }}>
|
||||
<MapView
|
||||
style={{ flex: 1, width: "100%", height: "100%" }}
|
||||
showsUserLocation={true}
|
||||
loadingEnabled={true}
|
||||
showsMyLocationButton={false}
|
||||
moveOnMarkerPress={false}
|
||||
showsCompass={false}
|
||||
ref={mapRef}
|
||||
//provider={PROVIDER_GOOGLE}
|
||||
initialRegion={{
|
||||
latitude: 33.774519,
|
||||
longitude: 133.533306,
|
||||
latitudeDelta: 1.8, //小さくなるほどズーム
|
||||
longitudeDelta: 1.8,
|
||||
}}
|
||||
>
|
||||
{stationPin.map(({ D, d, latlng, indexBase, index }) => (
|
||||
<MapPin
|
||||
index={index}
|
||||
indexBase={indexBase}
|
||||
latlng={latlng}
|
||||
D={D}
|
||||
d={d}
|
||||
navigate={navigate}
|
||||
webview={webview}
|
||||
key={D.StationNumber + d}
|
||||
/>
|
||||
))}
|
||||
</MapView>
|
||||
<View style={{ position: "relative" }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
position: "absolute",
|
||||
width: "100vw",
|
||||
bottom: 0,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
backgroundColor: selectedLine
|
||||
? lineColorList[stationIDPair[selectedLine]]
|
||||
: fixed.primary,
|
||||
padding: 10,
|
||||
zIndex: 1,
|
||||
alignItems: "center",
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
width: "100%",
|
||||
paddingBottom: 50,
|
||||
}}
|
||||
onPress={() => SheetManager.show("TrainMenuLineSelector")}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
fontSize: 10,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
▲ ここを押して路線をフィルタリングできます ▲
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
fontSize: 20,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{selectedLine
|
||||
? lineListPair[stationIDPair[selectedLine]]
|
||||
: "JR四国 対象全駅"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={{ position: "absolute", bottom: 40 }}>
|
||||
路線記号からフィルタリング
|
||||
</Text>
|
||||
{Object.keys(stationData).map((d) => (
|
||||
<TouchableOpacity
|
||||
key={stationIDPair[d]}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: lineColorList[stationIDPair[d]],
|
||||
padding: 5,
|
||||
margin: 2,
|
||||
borderRadius: 10,
|
||||
borderColor: "white",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
alignItems: "center",
|
||||
opacity: selectedLine == d ? 1 : !selectedLine ? 1 : 0.5,
|
||||
zIndex: 10,
|
||||
}}
|
||||
onPress={() => {
|
||||
const s = selectedLine == d ? undefined : d;
|
||||
if(!s) return;
|
||||
setSelectedLine(s);
|
||||
Object.keys(stationData).forEach((data, indexBase) => {
|
||||
stationData[data].forEach((D, index) => {
|
||||
if (!D.StationMap) return null;
|
||||
if (s && s != data) return;
|
||||
const latlng = D.StationMap.replace(
|
||||
"https://www.google.co.jp/maps/place/",
|
||||
""
|
||||
).split(",");
|
||||
if (latlng.length == 0) return null;
|
||||
if (index == 0 && stationPin.length > 0) {
|
||||
webview.current
|
||||
?.injectJavaScript(`MoveDisplayStation('${data}_${D.MyStation}_${D.Station_JP}');
|
||||
document.getElementById("disp").insertAdjacentHTML("afterbegin", "<div />");`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{ color: "white", fontWeight: "bold", fontSize: 20 }}
|
||||
>
|
||||
{stationIDPair[d]}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
{navigate && (
|
||||
<View style={{ flexDirection: "row" }}>
|
||||
<UsefulBox
|
||||
backgroundColor={"#F89038"}
|
||||
icon="train-car"
|
||||
flex={1}
|
||||
onPressButton={() =>
|
||||
navigate("howto", {
|
||||
info: "https://train.jr-shikoku.co.jp/usage.htm",
|
||||
})
|
||||
}
|
||||
>
|
||||
使い方
|
||||
</UsefulBox>
|
||||
<UsefulBox
|
||||
backgroundColor={"#EA4752"}
|
||||
icon="star"
|
||||
flex={1}
|
||||
onPressButton={() => navigate("favoriteList")}
|
||||
>
|
||||
お気に入り
|
||||
</UsefulBox>
|
||||
<UsefulBox
|
||||
backgroundColor={"#91C31F"}
|
||||
icon="clipboard-list-outline"
|
||||
flex={1}
|
||||
onPressButton={() =>
|
||||
Linking.openURL(
|
||||
"https://nexcloud.haruk.in/apps/forms/ZRHjWFF7znr5Xjr2"
|
||||
)
|
||||
}
|
||||
>
|
||||
フィードバック
|
||||
</UsefulBox>
|
||||
</View>
|
||||
)}
|
||||
<MapsButton
|
||||
onPress={() => {
|
||||
goBack();
|
||||
}}
|
||||
top={0}
|
||||
mapSwitch={"flex"}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { getCurrentTrainData } from "@/lib/getCurrentTrainData";
|
||||
import type { NavigateFunction } from "@/types";
|
||||
import { PlatformNumber } from "./LED_inside_Component/PlatformNumber";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||||
|
||||
type Props = {
|
||||
d: eachTrainDiagramType;
|
||||
@@ -48,6 +49,7 @@ export const EachData: FC<Props> = (props) => {
|
||||
} = props;
|
||||
const { currentTrain } = useCurrentTrain();
|
||||
const { stationList } = useStationList();
|
||||
const { playbackCurrentTimeIso } = useTrainMenu();
|
||||
const { allCustomTrainData } = useAllTrainDiagram();
|
||||
const { fixed } = useThemeColors();
|
||||
const openTrainInfo = (d: {
|
||||
@@ -134,7 +136,7 @@ export const EachData: FC<Props> = (props) => {
|
||||
const [h, m] = d.time.split(":");
|
||||
const IntH = parseInt(h);
|
||||
const IntM = parseInt(m);
|
||||
const currentTime = dayjs();
|
||||
const currentTime = playbackCurrentTimeIso ? dayjs(playbackCurrentTimeIso) : dayjs();
|
||||
const trainTime = currentTime
|
||||
.set("hour", IntH < 4 ? IntH + 24 : IntH)
|
||||
.set("minute", IntM);
|
||||
@@ -145,7 +147,7 @@ export const EachData: FC<Props> = (props) => {
|
||||
setIsDepartureNow(false);
|
||||
setIsShow(true);
|
||||
};
|
||||
}, [d.time, currentTrainData]);
|
||||
}, [d.time, currentTrainData, playbackCurrentTimeIso]);
|
||||
useInterval(() => {
|
||||
if (isDepartureNow) {
|
||||
setIsShow(!isShow);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, FC } from "react";
|
||||
import { View, useWindowDimensions, Text } from "react-native";
|
||||
import React, { useState, useEffect, FC, useCallback, useRef } from "react";
|
||||
import { View, useWindowDimensions, Text, Platform } from "react-native";
|
||||
import { objectIsEmpty } from "@/lib/objectIsEmpty";
|
||||
import { useCurrentTrain } from "@/stateBox/useCurrentTrain";
|
||||
import { useAreaInfo } from "@/stateBox/useAreaInfo";
|
||||
@@ -13,7 +13,32 @@ import { getTime, trainTimeFiltering } from "@/lib/trainTimeFiltering";
|
||||
import { eachTrainDiagramType, StationProps } from "@/lib/CommonTypes";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { getCurrentTrainData } from "@/lib/getCurrentTrainData";
|
||||
import {
|
||||
useAudioPlayer,
|
||||
useAudioPlayerStatus,
|
||||
setAudioModeAsync,
|
||||
} from "expo-audio";
|
||||
import { useInterval } from "@/lib/useInterval";
|
||||
import {
|
||||
buildVoicepeakAnnouncementKey,
|
||||
buildVoicepeakAnnouncementText,
|
||||
getVoicepeakAnnouncementStage,
|
||||
hasVoicepeakConfiguration,
|
||||
loadVoicepeakSettings,
|
||||
requestVoicepeakSpeeches,
|
||||
type VoicepeakSettings,
|
||||
} from "@/lib/voicepeak";
|
||||
import {
|
||||
EMPTY_NATIVE_VOICEPEAK_HTML,
|
||||
type PreparedVoicepeakAudio,
|
||||
} from "@/lib/voicepeakAudioSource";
|
||||
import { WebView } from "react-native-webview";
|
||||
import { stackAwareNavigate } from "@/lib/rootNavigation";
|
||||
import { useStationList } from "@/stateBox/useStationList";
|
||||
import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||||
import { checkDuplicateTrainData } from "@/lib/checkDuplicateTrainData";
|
||||
import { trainPosition } from "@/lib/trainPositionTextArray";
|
||||
|
||||
const readBooleanSetting = async (key: string) => {
|
||||
try {
|
||||
@@ -55,11 +80,38 @@ 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();
|
||||
const [stationDiagram, setStationDiagram] = useState<{
|
||||
[key: string]: string;
|
||||
}>({}); //当該駅の全時刻表
|
||||
@@ -68,10 +120,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 +168,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]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// 現在の駅に停車するダイヤを作成する副作用[列車ダイヤと現在駅情報]
|
||||
@@ -121,10 +228,316 @@ export const LED_vision: FC<props> = (props) => {
|
||||
if (!currentTrain) return () => {};
|
||||
const data = trainTimeAndNumber
|
||||
.filter((d) => currentTrain.map((m) => m.num).includes(d.train)) //現在の列車に絞る[ToDo]
|
||||
.filter((d) => trainTimeFiltering({ d, currentTrain, station })) //時間フィルター
|
||||
.filter((d) => trainTimeFiltering({ d, currentTrain, station, stationList, now: playbackCurrentTimeIso })) //時間フィルター
|
||||
.filter((d) => !!finalSwitch || d.lastStation != station[0].Station_JP); //最終列車表示設定
|
||||
setSelectedTrain(data);
|
||||
}, [trainTimeAndNumber, currentTrain, finalSwitch]);
|
||||
}, [trainTimeAndNumber, currentTrain, finalSwitch, stationList, playbackCurrentTimeIso]);
|
||||
|
||||
const getVoicepeakCandidates = useCallback(() => {
|
||||
if (!currentTrain?.length || !allCustomTrainData) return [];
|
||||
|
||||
const activeTrainNumbers = new Set(currentTrain.map((train) => train.num));
|
||||
const candidateTrains = new Map(
|
||||
selectedTrain.map((train) => [train.train, train])
|
||||
);
|
||||
trainTimeAndNumber.forEach((train) => {
|
||||
if (train.isThrough && activeTrainNumbers.has(train.train)) {
|
||||
candidateTrains.set(train.train, train);
|
||||
}
|
||||
});
|
||||
|
||||
return [...candidateTrains.values()]
|
||||
.flatMap((train) => {
|
||||
const currentTrainData = getCurrentTrainData(
|
||||
train.train,
|
||||
currentTrain,
|
||||
allCustomTrainData
|
||||
);
|
||||
const currentTrainStatuses = currentTrain.filter(
|
||||
(currentTrainItem) => currentTrainItem.num === train.train
|
||||
);
|
||||
const currentTrainStatus =
|
||||
currentTrainStatuses.length > 1
|
||||
? checkDuplicateTrainData(currentTrainStatuses, stationList) ??
|
||||
currentTrainStatuses[0]
|
||||
: currentTrainStatuses[0];
|
||||
const position = currentTrainStatus
|
||||
? trainPosition(currentTrainStatus)
|
||||
: null;
|
||||
const isStoppedAtStation =
|
||||
position?.isBetween === false &&
|
||||
position.Pos.Pos === station[0].Station_JP;
|
||||
const delayMinutes =
|
||||
typeof currentTrainStatus?.delay === "number"
|
||||
? currentTrainStatus.delay
|
||||
: 0;
|
||||
|
||||
if (!currentTrainData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const getStage = (timingTrain: eachTrainDiagramType) =>
|
||||
getVoicepeakAnnouncementStage({
|
||||
station: station[0],
|
||||
train: timingTrain,
|
||||
currentTrainData,
|
||||
delayMinutes,
|
||||
});
|
||||
const buildCandidate = (
|
||||
timingTrain: eachTrainDiagramType,
|
||||
stage: NonNullable<ReturnType<typeof getVoicepeakAnnouncementStage>>,
|
||||
advanceTimeBasis: "arrival" | "departure" = "departure"
|
||||
): VoicepeakCandidate => ({
|
||||
key: buildVoicepeakAnnouncementKey(station[0], timingTrain, stage),
|
||||
text: buildVoicepeakAnnouncementText({
|
||||
station: station[0],
|
||||
train: timingTrain,
|
||||
currentTrainData,
|
||||
stage,
|
||||
delayMinutes,
|
||||
isOrigin: timingTrain.isOrigin === true,
|
||||
isStoppedAtStation,
|
||||
advanceTimeBasis,
|
||||
}),
|
||||
departureTime: timingTrain.time,
|
||||
priority:
|
||||
stage === "passing" ? 0 : stage === "departure" ? 1 : 2,
|
||||
});
|
||||
|
||||
if (train.arrivalTime && train.departureTime) {
|
||||
const candidates: VoicepeakCandidate[] = [];
|
||||
const arrivalTimingTrain = { ...train, time: train.arrivalTime };
|
||||
const arrivalStage = getStage(arrivalTimingTrain);
|
||||
if (arrivalStage === "advance" || arrivalStage === "departure") {
|
||||
candidates.push(
|
||||
buildCandidate(arrivalTimingTrain, "advance", "arrival")
|
||||
);
|
||||
}
|
||||
|
||||
const departureTimingTrain = { ...train, time: train.departureTime };
|
||||
const departureStage = getStage(departureTimingTrain);
|
||||
const dwellMinutes = getDwellMinutes(
|
||||
train.arrivalTime,
|
||||
train.departureTime
|
||||
);
|
||||
if (departureStage === "departure") {
|
||||
candidates.push(
|
||||
buildCandidate(departureTimingTrain, "departure")
|
||||
);
|
||||
} else if (
|
||||
departureStage === "advance" &&
|
||||
dwellMinutes !== null &&
|
||||
dwellMinutes >= 3
|
||||
) {
|
||||
candidates.push(buildCandidate(departureTimingTrain, "advance"));
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
const stage = getStage(train);
|
||||
return stage ? [buildCandidate(train, stage)] : [];
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.priority - b.priority ||
|
||||
a.departureTime.localeCompare(b.departureTime)
|
||||
);
|
||||
}, [
|
||||
allCustomTrainData,
|
||||
currentTrain,
|
||||
selectedTrain,
|
||||
station,
|
||||
stationList,
|
||||
trainTimeAndNumber,
|
||||
]);
|
||||
|
||||
const finishVoicepeakPlayback = useCallback(() => {
|
||||
isExpoVoicepeakPlayingRef.current = false;
|
||||
isNativeVoicepeakPlayingRef.current = false;
|
||||
cleanupAudioRef.current?.();
|
||||
cleanupAudioRef.current = undefined;
|
||||
pendingVoicepeakAudiosRef.current.forEach((audio) => {
|
||||
if (audio.kind === "expo-audio") audio.cleanup?.();
|
||||
});
|
||||
pendingVoicepeakAudiosRef.current = [];
|
||||
isVoicepeakBusyRef.current = false;
|
||||
|
||||
if (isVoicepeakMountedRef.current) {
|
||||
drainVoicepeakQueueRef.current();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startVoicepeakAudio = useCallback(
|
||||
async (audio: PreparedVoicepeakAudio) => {
|
||||
cleanupAudioRef.current?.();
|
||||
cleanupAudioRef.current =
|
||||
audio.kind === "expo-audio" ? audio.cleanup : undefined;
|
||||
|
||||
await setAudioModeAsync({
|
||||
playsInSilentMode: true,
|
||||
shouldPlayInBackground: false,
|
||||
interruptionMode: "duckOthers",
|
||||
});
|
||||
|
||||
if (audio.kind === "native-webview") {
|
||||
isNativeVoicepeakPlayingRef.current = true;
|
||||
setNativeVoicepeakHtml(audio.html);
|
||||
setNativeVoicepeakPlaybackKey((current) => current + 1);
|
||||
} else {
|
||||
announcementPlayer.replace(audio.source);
|
||||
announcementPlayer.volume = 1;
|
||||
await announcementPlayer.seekTo(0);
|
||||
isExpoVoicepeakPlayingRef.current = true;
|
||||
announcementPlayer.play();
|
||||
}
|
||||
},
|
||||
[announcementPlayer]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
startVoicepeakAudioRef.current = startVoicepeakAudio;
|
||||
}, [startVoicepeakAudio]);
|
||||
|
||||
const completeVoicepeakAudioSegment = useCallback(() => {
|
||||
isExpoVoicepeakPlayingRef.current = false;
|
||||
isNativeVoicepeakPlayingRef.current = false;
|
||||
cleanupAudioRef.current?.();
|
||||
cleanupAudioRef.current = undefined;
|
||||
|
||||
const nextAudio = pendingVoicepeakAudiosRef.current.shift();
|
||||
if (!nextAudio || !isVoicepeakMountedRef.current) {
|
||||
finishVoicepeakPlayback();
|
||||
return;
|
||||
}
|
||||
|
||||
void startVoicepeakAudioRef.current(nextAudio).catch((error) => {
|
||||
console.warn("Failed to play Voicepeak announcement segment", error);
|
||||
finishVoicepeakPlayback();
|
||||
});
|
||||
}, [finishVoicepeakPlayback]);
|
||||
|
||||
const playVoicepeakAnnouncementBatch = useCallback(
|
||||
async (candidates: VoicepeakCandidate[]) => {
|
||||
if (!voicepeakSettings || !hasVoicepeakConfiguration(voicepeakSettings)) {
|
||||
candidates.forEach((candidate) => {
|
||||
reservedAnnouncementKeysRef.current.delete(candidate.key);
|
||||
});
|
||||
isVoicepeakBusyRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const combinedText = candidates
|
||||
.map((candidate) => candidate.text)
|
||||
.join("続いて、");
|
||||
const controller = new AbortController();
|
||||
currentRequestRef.current = controller;
|
||||
|
||||
try {
|
||||
const audios = await requestVoicepeakSpeeches({
|
||||
text: combinedText,
|
||||
settings: voicepeakSettings,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!isVoicepeakMountedRef.current) {
|
||||
audios.forEach((audio) => {
|
||||
if (audio.kind === "expo-audio") audio.cleanup?.();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const [firstAudio, ...remainingAudios] = audios;
|
||||
if (!firstAudio) {
|
||||
throw new Error("Voicepeak returned no announcement audio");
|
||||
}
|
||||
pendingVoicepeakAudiosRef.current = remainingAudios;
|
||||
await startVoicepeakAudio(firstAudio);
|
||||
|
||||
candidates.forEach((candidate) => {
|
||||
announcedKeysRef.current.add(candidate.key);
|
||||
reservedAnnouncementKeysRef.current.delete(candidate.key);
|
||||
});
|
||||
} catch (error) {
|
||||
candidates.forEach((candidate) => {
|
||||
reservedAnnouncementKeysRef.current.delete(candidate.key);
|
||||
if (!controller.signal.aborted) {
|
||||
announcedKeysRef.current.add(candidate.key);
|
||||
}
|
||||
});
|
||||
if (!controller.signal.aborted) {
|
||||
console.warn("Failed to play Voicepeak announcement", error);
|
||||
}
|
||||
finishVoicepeakPlayback();
|
||||
} finally {
|
||||
if (currentRequestRef.current === controller) {
|
||||
currentRequestRef.current = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
[finishVoicepeakPlayback, startVoicepeakAudio, voicepeakSettings]
|
||||
);
|
||||
|
||||
const drainVoicepeakQueue = useCallback(() => {
|
||||
if (
|
||||
isVoicepeakBusyRef.current ||
|
||||
queuedAnnouncementsRef.current.size === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = [...queuedAnnouncementsRef.current.values()].sort(
|
||||
(a, b) =>
|
||||
a.priority - b.priority ||
|
||||
a.departureTime.localeCompare(b.departureTime)
|
||||
);
|
||||
queuedAnnouncementsRef.current.clear();
|
||||
isVoicepeakBusyRef.current = true;
|
||||
void playVoicepeakAnnouncementBatch(candidates);
|
||||
}, [playVoicepeakAnnouncementBatch]);
|
||||
|
||||
useEffect(() => {
|
||||
drainVoicepeakQueueRef.current = drainVoicepeakQueue;
|
||||
}, [drainVoicepeakQueue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isExpoVoicepeakPlayingRef.current &&
|
||||
announcementPlayerStatus.didJustFinish
|
||||
) {
|
||||
completeVoicepeakAudioSegment();
|
||||
}
|
||||
}, [
|
||||
announcementPlayerStatus.didJustFinish,
|
||||
completeVoicepeakAudioSegment,
|
||||
]);
|
||||
|
||||
const checkVoicepeakAnnouncement = useCallback(() => {
|
||||
if (!voicepeakSettings || !hasVoicepeakConfiguration(voicepeakSettings)) {
|
||||
return;
|
||||
}
|
||||
|
||||
getVoicepeakCandidates().forEach((candidate) => {
|
||||
if (
|
||||
announcedKeysRef.current.has(candidate.key) ||
|
||||
reservedAnnouncementKeysRef.current.has(candidate.key)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
reservedAnnouncementKeysRef.current.add(candidate.key);
|
||||
queuedAnnouncementsRef.current.set(candidate.key, candidate);
|
||||
});
|
||||
|
||||
drainVoicepeakQueue();
|
||||
}, [drainVoicepeakQueue, getVoicepeakCandidates, voicepeakSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
checkVoicepeakAnnouncement();
|
||||
}, [checkVoicepeakAnnouncement]);
|
||||
|
||||
useInterval(() => {
|
||||
checkVoicepeakAnnouncement();
|
||||
}, 1000);
|
||||
|
||||
const { width } = useWindowDimensions();
|
||||
const adjustedWidth = width * 0.98;
|
||||
@@ -138,6 +551,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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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・データ結合を各端末側で担当している。
|
||||
|
||||
最優先は、バックエンドを単なるデータ取得先ではなく「端末向けBFF(Backend 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 positions(15秒)
|
||||
│ ├─ backend-api: train-data(30秒)
|
||||
│ ├─ backend-api: operation-logs(30秒)
|
||||
│ ├─ data-storage: diagram-today(30秒)
|
||||
│ ├─ n8n + GAS: operation flag/text(60秒)
|
||||
│ ├─ GAS: delay, train-pair, bus/train
|
||||
│ └─ backend-api: permission
|
||||
│
|
||||
├─ 走行位置WebView
|
||||
│ ├─ JR四国公式ページ/XHR
|
||||
│ ├─ backend-api: train-data(30秒)
|
||||
│ ├─ backend-api: operation-logs(30秒)
|
||||
│ ├─ data-storage: diagram-today(30秒)
|
||||
│ ├─ 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 | positions(15秒) | 約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`
|
||||
@@ -0,0 +1,522 @@
|
||||
# バックグラウンドりっかちゃん通知・Live Activity バックエンド連携計画(素案)
|
||||
|
||||
> ステータス: 相談用ドラフト
|
||||
> 作成日: 2026-07-19
|
||||
> 目的: n8n・Expo Push・APNs・ActivityKitを利用し、アプリが前面にいない状態でも列車追従アナウンスとDynamic Island更新を成立させる。
|
||||
|
||||
## 1. 結論
|
||||
|
||||
既存の通知システムは大部分を再利用できる。
|
||||
|
||||
- 通知対象の登録、購読リスト管理、列車位置の監視、送信判定はn8nを継続利用する。
|
||||
- 通常の表示通知は、まず既存のExpo Push経路を利用する。
|
||||
- Dynamic Island(Live 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 データ鮮度
|
||||
|
||||
列車位置情報が一定時間更新されていない場合、次駅変化を確定しない。
|
||||
|
||||
初期案:
|
||||
|
||||
- 位置情報の取得周期: 15~30秒
|
||||
- 情報の許容鮮度: 90秒
|
||||
- 同じ変化を2回連続で観測した場合に確定。ただし情報源がイベント時刻や連番を持つ場合は再検討する。
|
||||
- 終着または有効期限超過で監視終了
|
||||
|
||||
## 8. n8nワークフロー案
|
||||
|
||||
### Workflow A: 購読登録・更新
|
||||
|
||||
1. アプリから署名付きリクエストを受け取る。
|
||||
2. スキーマ、通知許可、運行日、有効期限を検証する。
|
||||
3. `subscriptionId` をキーにupsertする。
|
||||
4. 同じ端末・同じ列車の古い購読を無効化する。
|
||||
5. 登録結果とサーバー時刻を返す。
|
||||
|
||||
### Workflow B: 列車位置監視
|
||||
|
||||
1. 有効な購読を列車単位で集約する。
|
||||
2. 同じ列車の位置情報は一度だけ取得する。
|
||||
3. 前回状態と比較して、次駅・現在位置・遅延の変化を算出する。
|
||||
4. データ鮮度と連続観測条件を検証する。
|
||||
5. 新しい `eventId` をイベントキューへ登録する。
|
||||
|
||||
### Workflow C: 通常通知送信
|
||||
|
||||
1. イベントに該当する購読者を抽出する。
|
||||
2. `preparedSounds[nextStation]` の有無を確認する。
|
||||
3. 最大100件単位でExpo Pushへ送る。
|
||||
4. Expo Push Ticketを保存する。
|
||||
5. 後続処理でPush Receiptを取得する。
|
||||
6. `DeviceNotRegistered` のExpo Tokenを無効化する。
|
||||
7. 429・5xxは指数バックオフ付きで再試行する。
|
||||
|
||||
### Workflow D: Live Activity更新
|
||||
|
||||
1. ActivityKit Push Tokenを持つ購読だけ抽出する。
|
||||
2. APNs JWTを生成または再利用する。
|
||||
3. `liveactivity` 用ヘッダーと `content-state` を送る。
|
||||
4. APNs応答を保存する。
|
||||
5. 無効・期限切れTokenを無効化する。
|
||||
6. 通常更新は優先度5、次駅変化等の主要イベントは必要に応じて10とする。
|
||||
|
||||
### Workflow E: 購読終了・清掃
|
||||
|
||||
以下の条件で購読を終了する。
|
||||
|
||||
- ユーザーが追従停止を操作
|
||||
- 別列車へ追従対象を変更
|
||||
- 列車が終着
|
||||
- `expiresAt` 超過
|
||||
- Push Tokenが無効
|
||||
- 数時間にわたり列車データを取得できない
|
||||
|
||||
## 9. API素案
|
||||
|
||||
### `POST /v1/tracking-subscriptions`
|
||||
|
||||
追従開始。`subscriptionId` を指定した再送は冪等に処理する。
|
||||
|
||||
### `PATCH /v1/tracking-subscriptions/{subscriptionId}`
|
||||
|
||||
ActivityKit Push Token、Expo Push Token、準備済み音声一覧などを更新する。
|
||||
|
||||
### `DELETE /v1/tracking-subscriptions/{subscriptionId}`
|
||||
|
||||
追従停止。既存Live Activityをバックエンド側から終了させる必要がある場合は、削除前に `event: end` を送る。
|
||||
|
||||
### `POST /v1/tracking-subscriptions/{subscriptionId}/heartbeat`
|
||||
|
||||
初期版では必須にしない。将来、端末状態や購読継続確認が必要になった場合だけ追加する。
|
||||
|
||||
## 10. アプリ側の変更計画
|
||||
|
||||
### 10.1 ActivityKit対応
|
||||
|
||||
- `Activity.request(..., pushType: nil)` を `pushType: .token` へ変更する。
|
||||
- `activity.pushTokenUpdates` を監視する。
|
||||
- Activity IDとPush TokenをJSへ通知するExpo Module APIを追加する。
|
||||
- Token変更、Activity終了、追従解除をバックエンドへ反映する。
|
||||
- Live Activityの `ContentState` とAPNs `content-state` の型を完全一致させる。
|
||||
|
||||
### 10.2 追従登録
|
||||
|
||||
- 音声生成が完了した駅とファイル名を収集する。
|
||||
- Expo Push Token、ActivityKit Push Token、列車情報を購読APIへ登録する。
|
||||
- 登録中、登録済み、部分成功、失敗をUIで区別する。
|
||||
- 追従停止時は購読削除を送る。通信失敗時はローカルに停止要求を保持して再試行する。
|
||||
|
||||
### 10.3 通知処理
|
||||
|
||||
- 通知タップ時に対象列車画面へ遷移する。
|
||||
- `schemaVersion` と `type` を検証し、未知のPayloadを安全に無視する。
|
||||
- フォアグラウンド受信時にも二重音声再生しない。
|
||||
- 端末内通知音が欠損している場合の挙動を実機確認する。
|
||||
|
||||
## 11. APNs認証情報と運用
|
||||
|
||||
APNs直接送信には最低限、以下が必要になる。
|
||||
|
||||
- Apple DeveloperのAPNs Auth Key(`.p8`)
|
||||
- Key ID
|
||||
- Team ID
|
||||
- Bundle ID
|
||||
- development / production環境の識別
|
||||
|
||||
`.p8` と署名用秘密情報はアプリ、Git、n8nワークフロー定義へ直接埋め込まない。n8n Credentialsまたは専用のSecrets管理へ保存する。
|
||||
|
||||
n8nからAPNs HTTP/2とJWT処理を安定して扱えない場合、APNs送信だけを小さなCloudflare Worker、Lambda、Cloud Run等へ分離し、n8nはその内部APIを呼ぶ。
|
||||
|
||||
## 12. セキュリティ・プライバシー
|
||||
|
||||
- Expo Push Tokenを認証済みユーザーIDとして扱わない。
|
||||
- `installationId` と購読用の署名または短期トークンを導入する。
|
||||
- 登録APIをレート制限する。
|
||||
- 他人の `subscriptionId` を推測して更新・削除できないようにする。
|
||||
- Push Tokenをログ本文へそのまま出さず、必要なら末尾数文字のみ記録する。
|
||||
- 保存する情報は追従に必要な列車、運行日、通知Tokenに限定する。
|
||||
- 有効期限超過後は速やかに削除する。
|
||||
|
||||
## 13. 障害時の挙動
|
||||
|
||||
| 障害 | 推奨挙動 |
|
||||
|---|---|
|
||||
| 列車位置API停止 | 古い位置から通知せず、Live Activityをstale表示にする |
|
||||
| Expo Push一時障害 | 指数バックオフで再試行。ただし次駅通知のTTL超過後は破棄 |
|
||||
| APNs一時障害 | 短時間再試行し、次の状態更新で上書き可能にする |
|
||||
| 音声未準備 | 通常音または音なしの表示通知へフォールバック |
|
||||
| Activity Token無効 | Dynamic Island更新のみ停止し、通常通知は継続 |
|
||||
| Expo Token無効 | 通常通知を停止し、購読を無効化または端末再登録待ちにする |
|
||||
| 重複イベント | `eventId` と購読単位の送信履歴で抑止 |
|
||||
| 位置情報が逆行・飛躍 | 連続観測と路線順序検証で確定を保留 |
|
||||
|
||||
## 14. 段階的な実装計画
|
||||
|
||||
### Phase 0: 技術PoC
|
||||
|
||||
- [ ] 実機で端末の `Library/Sounds` に動的WAVを保存する。
|
||||
- [ ] n8nからExpo Pushの `sound` にそのファイル名を指定する。
|
||||
- [ ] アプリが前面、背面、終了状態の3条件で音声再生を確認する。
|
||||
- [ ] マナーモード、集中モード、Bluetooth、他アプリ音声再生中も確認する。
|
||||
- [ ] Expo Push TicketとReceiptを保存・確認する。
|
||||
- [ ] ActivityKit Push Tokenを取得する最小実装を作る。
|
||||
- [ ] APNsから1回だけLive Activity更新を送る。
|
||||
|
||||
**判定ゲート:**
|
||||
|
||||
- 動的WAVがExpo経由で安定再生する → 通常通知はExpoを継続。
|
||||
- 動的WAVが無音、デフォルト音、環境依存になる → 通常通知もAPNs直送へ変更。
|
||||
|
||||
### Phase 1: 列車追従MVP
|
||||
|
||||
- [ ] 列車追従購読APIを作成する。
|
||||
- [ ] 列車単位の共有ポーリングと次駅変化判定を作る。
|
||||
- [ ] `eventId` による重複防止を実装する。
|
||||
- [ ] 「次は、○○です。」通常Push通知を実装する。
|
||||
- [ ] 終着・停止・期限切れ処理を実装する。
|
||||
- [ ] n8n上で送信状況を確認できるログを用意する。
|
||||
|
||||
### Phase 2: Dynamic Island連携
|
||||
|
||||
- [ ] Live Activityを `pushType: .token` で開始する。
|
||||
- [ ] Token更新を購読APIへ同期する。
|
||||
- [ ] APNs Live Activity更新処理を実装する。
|
||||
- [ ] 次駅、現在位置、遅延、到着予定を同期する。
|
||||
- [ ] stale、終了、Token無効時の処理を実装する。
|
||||
|
||||
### Phase 3: 駅固定モードと運用品質
|
||||
|
||||
- [ ] 駅固定購読を同じデータモデルへ追加する。
|
||||
- [ ] 遅延・接近・発車イベントを追加する。
|
||||
- [ ] 監視、メトリクス、失敗通知を整備する。
|
||||
- [ ] 負荷試験とAPI障害試験を実施する。
|
||||
- [ ] 不要Tokenと期限切れ購読の自動清掃を実装する。
|
||||
|
||||
## 15. テスト項目
|
||||
|
||||
### 15.1 通知音
|
||||
|
||||
- 対象WAVあり / なし
|
||||
- アプリ前面 / 背面 / 終了
|
||||
- 通常モード / マナーモード / 集中モード
|
||||
- 端末スピーカー / Bluetooth / CarPlay相当環境
|
||||
- 音楽や動画再生中
|
||||
- 通知許可あり / サウンドのみ拒否 / 通知拒否
|
||||
|
||||
iOSのユーザー設定や集中モードをアプリ側から回避する設計にはしない。
|
||||
|
||||
### 15.2 列車位置判定
|
||||
|
||||
- 通常走行
|
||||
- 長時間停車
|
||||
- 遅延
|
||||
- 位置情報欠落
|
||||
- 位置の逆行または瞬間的な誤値
|
||||
- 途中駅通過
|
||||
- 列車番号重複と日付跨ぎ
|
||||
- 併結、分割、列車番号変更がデータ上存在する場合
|
||||
|
||||
### 15.3 PushとLive Activity
|
||||
|
||||
- Token更新
|
||||
- 無効Token
|
||||
- 二重送信
|
||||
- 順不同到着
|
||||
- 古いPayload到着
|
||||
- APNs 410等のエラー
|
||||
- Activityがユーザーによって終了された場合
|
||||
- 追従停止直後に送信イベントが競合した場合
|
||||
|
||||
## 16. App Review上の説明方針
|
||||
|
||||
本機能はバックグラウンドで継続的にオーディオ再生するものではない。列車位置判定はサーバー側で行い、利用者が明示的に登録した追従条件に対して、通常のユーザー通知として短い音声を再生する。
|
||||
|
||||
そのため、初期方針では `UIBackgroundModes = audio` を要求しない。Live ActivityはActivityKitとAPNsの正規手段で更新する。
|
||||
|
||||
Review Notesでは以下を簡潔に説明する。
|
||||
|
||||
- ユーザーが列車追従を明示的に開始・停止できること
|
||||
- 通知と音声を設定画面で無効化できること
|
||||
- 音声は通知到着時だけ短時間再生されること
|
||||
- バックグラウンドで常時音声処理を行わないこと
|
||||
- Dynamic IslandはActivityKit Pushで更新すること
|
||||
|
||||
## 17. 未決事項
|
||||
|
||||
別の設計レビューでは、特に以下を確認したい。
|
||||
|
||||
1. Expo Push経由で `Library/Sounds` の動的WAV指定が実運用上保証できるか。
|
||||
2. n8nからAPNs HTTP/2へ直接送るか、署名・再試行を担当する小規模Push APIを分離するか。
|
||||
3. 列車位置変化を「1回で確定」するか「2回連続観測で確定」するか。
|
||||
4. 次駅アナウンスの発火点を、区間進入時、前駅発車時、次駅接近時のどこに置くか。
|
||||
5. 音声準備に一部失敗した状態で追従開始を許可するか。
|
||||
6. 駅固定モードをMVPへ含めるか、列車追従の安定後に追加するか。
|
||||
7. APNs認証情報をn8nで保持するか、専用Push APIでのみ保持するか。
|
||||
8. Live Activityの開始を常にアプリ操作必須とするか、将来Push-to-startを検討するか。
|
||||
9. Voicepeak設定変更後、同じファイル名の音声をいつ再生成するか。
|
||||
10. 列車走行位置情報APIの利用条件、更新間隔、障害時保証をどこまで前提にできるか。
|
||||
|
||||
## 18. 推奨する初期判断
|
||||
|
||||
- MVPは列車追従モードだけに絞る。
|
||||
- n8nの既存購読リストとExpo一斉送信を維持する。
|
||||
- Phase 0の実機PoCを最優先し、動的音声の配信経路を先に確定する。
|
||||
- Dynamic IslandはExpo経由を試さず、最初からAPNs直接送信とする。
|
||||
- APNs送信処理は将来の再利用性と秘密鍵管理を考え、可能ならn8n外の小規模APIへ分離する。
|
||||
- アプリが閉じていても通知は成立させるが、初期版では追従開始そのものはアプリ操作を必須とする。
|
||||
- `UIBackgroundModes = audio` は復活させない。
|
||||
|
||||
## 19. 参考資料
|
||||
|
||||
- [Apple: Generating a remote notification](https://developer.apple.com/documentation/usernotifications/generating-a-remote-notification)
|
||||
- [Apple: Starting and updating Live Activities with ActivityKit push notifications](https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications)
|
||||
- [Expo: Send notifications with the Expo Push Service](https://docs.expo.dev/push-notifications/sending-notifications/)
|
||||
- [Expo: Notifications SDK](https://docs.expo.dev/versions/latest/sdk/notifications/)
|
||||
- [既存のiOS Live Activity Push計画](./ios-live-activity-push-plan.md)
|
||||
- [既存のサウンド機能計画](./sound-feature-plan.md)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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. 公開予定日と、アプリ側が切り替えてよいタイミング
|
||||
@@ -0,0 +1,153 @@
|
||||
# X投稿向け運行情報画像セット:実装指示書
|
||||
|
||||
## 依頼
|
||||
|
||||
`ndView.tsx` に、X(旧Twitter)へ複数画像で投稿することを前提とした運行情報画像セット生成機能を実装してください。
|
||||
|
||||
既存の「この項目を切り出す」「この小見出しを切り出す」「表示中の運行情報をすべて切り出す」「項目ごとに複数画像で共有」は残し、新たに「X投稿向け画像を作成」を追加します。
|
||||
|
||||
実装まで行い、型チェックと生成処理の確認をしてください。調査・提案だけで終了しないでください。
|
||||
|
||||
## ユーザー体験
|
||||
|
||||
新しいボタンを押すと、次の順番で最大4枚のPNGを生成し、既存の複数ファイル共有処理へ渡します。
|
||||
|
||||
1. 1枚目:運行状況の表紙(路線図+短い要約)
|
||||
2. 2枚目以降:表示中の運行情報の詳細
|
||||
|
||||
Xで1枚目と2枚目が横並びに表示されたときに自然につながって見える構成にします。ただし、各画像は単体表示されても意味が通じる必要があります。画像の境界をまたいで文章や必須情報を配置しないでください。
|
||||
|
||||
## 出力仕様
|
||||
|
||||
- 各画像は `1080 x 1440 px`(3:4)の固定サイズ
|
||||
- PNG
|
||||
- 画像数は1〜4枚。運行情報が存在する通常ケースでは表紙+詳細で2〜4枚
|
||||
- ファイル名は投稿順を明確にする
|
||||
- `operation-info-x-01-map-<timestamp>.png`
|
||||
- `operation-info-x-02-detail-<timestamp>.png`
|
||||
- 以下同様
|
||||
- ネイティブ側へ送る `batchIndex` はゼロ始まりの表示順と一致させる
|
||||
- 既存の `Share.open({ urls })` に渡る配列順を維持する
|
||||
- 画像生成途中で1枚でも失敗した場合、不完全なセットを共有せず既存形式のエラーを返す
|
||||
|
||||
## 1枚目:表紙
|
||||
|
||||
必須要素:
|
||||
|
||||
- `JR四国 列車運行情報` のタイトル
|
||||
- 現在表示中の状態を示す短い要約
|
||||
- 四国の路線図
|
||||
- 更新日時(取得できる最新のもの)
|
||||
- 詳細ページがある場合は `詳細は次の画像へ →`
|
||||
- 非公式アプリによる生成画像であり、最新情報はJR四国公式を確認する旨
|
||||
- ページ番号(例:`1/4`)
|
||||
|
||||
路線図は既存の `buildMapImage()` と `img/map/*` を再利用します。現在ページ上で有効になっている路線レイヤーを反映してください。既存挙動を壊さない範囲で、影響路線を目立たせ、影響のない部分を弱められるなら実装してください。状態と路線の対応を正確に判定できない場合は、誤った色分けを推測せず、現在表示されているレイヤー表現を使用してください。
|
||||
|
||||
表紙は路線図を主役にし、要約文が長い場合も地図を極端に小さくしないでください。要約は路線名・状態を中心に短く整形し、詳細本文を表紙へ詰め込みません。
|
||||
|
||||
## 2枚目以降:詳細ページ
|
||||
|
||||
必須要素:
|
||||
|
||||
- 路線・項目タイトル
|
||||
- サブタイトル/状態
|
||||
- 更新日時
|
||||
- 小見出しと本文
|
||||
- ページ番号
|
||||
- 非公式画像の注意書き
|
||||
|
||||
既存の `getOperationItems()`、`parseDetailBlocks()`、`splitCaptureBlocksIntoSections()` のデータ構造を再利用します。
|
||||
|
||||
詳細は「1項目=必ず1画像」ではなく、固定高のページへ順に組版してください。
|
||||
|
||||
- 1件が短ければ、同じページに次の項目も配置してよい
|
||||
- 1件が長ければ、ブロックまたは小見出し単位で次ページへ送る
|
||||
- 小見出しだけをページ末尾に残さない
|
||||
- 本文を文字単位で不自然に分断するより、小見出し単位の改ページを優先する
|
||||
- 4枚を超える場合は、詳細3ページへ読みやすく収める
|
||||
- それでも収まらない場合は、文字を極端に縮小したり内容を黙って欠落させたりしない
|
||||
|
||||
4枚で収まらないケースについては、実装上安全な扱いを選び、コードコメントと完了報告で明示してください。推奨は、最小可読サイズを守ったうえで4枚では収まらないことをユーザーへ知らせ、既存の項目別共有を案内することです。
|
||||
|
||||
## レイアウト規則
|
||||
|
||||
- 背景は白を基本とし、既存のJR四国風配色(`#0099CB` など)を継承
|
||||
- 左右80px程度をX側のクロップに備えた安全領域とする
|
||||
- 必須情報は端へ寄せない
|
||||
- 全ページでヘッダー、ページ番号、フッターの位置を統一
|
||||
- 本文の可読性を優先し、既存画像と同程度の文字サイズを維持
|
||||
- 1枚目右側の矢印などで2枚目への視線誘導を作ってよいが、その装飾が切れても意味が失われないようにする
|
||||
- 角丸や画像間の隙間はX側が付けるため、画像自体には外周の角丸を焼き込まない
|
||||
|
||||
## 既存コードの入口
|
||||
|
||||
主な対象は `ndView.tsx` です。
|
||||
|
||||
- `buildOperationPageScript()`:WebViewへ注入する生成処理
|
||||
- `buildMapImage()`:路線図Canvas合成
|
||||
- `appendCaptureItemLayout()` / `drawCaptureLayout()`:既存組版・描画
|
||||
- `getOperationItems()`:表示中の運行情報抽出
|
||||
- `installPageCaptureButton()`:共有ボタン追加
|
||||
- `renderAllItemsToSeparateImages()`:既存バッチ送信の参考
|
||||
- `saveOperationCaptureBatchItem()`:ネイティブ側で順番どおりにファイルを集約
|
||||
- `shareOperationCaptureFiles()`:単一/複数ファイル共有
|
||||
|
||||
必要ならX投稿向けのレイアウト計算・描画関数を独立させてください。既存の可変幅・可変高画像生成ロジックへ多数の条件分岐を足すより、データ抽出と基本描画だけ共有する構成を優先します。
|
||||
|
||||
## 実装上の制約
|
||||
|
||||
- 既存の切り出し/共有機能を壊さない
|
||||
- 横画面サイネージモードでは、従来どおりキャプチャボタンを表示しない
|
||||
- iOS・Android双方で動く既存のWebViewメッセージ方式を維持
|
||||
- 新しい依存パッケージは原則追加しない
|
||||
- WebView内で利用できない新しいJavaScript構文を安易に導入しない
|
||||
- 生成中の二重タップで複数バッチが競合しないよう考慮する
|
||||
- ユーザーの既存変更があるファイルを勝手に巻き戻さない
|
||||
- `scripts/generate-operation-info-userscript.mjs` が `ndView.tsx` の注入コードを元に生成物を作るため、必要なら生成物も更新する。ただし生成物を直接手編集しない
|
||||
|
||||
## UI文言
|
||||
|
||||
新規ボタン:
|
||||
|
||||
`X投稿向け画像を作成`
|
||||
|
||||
生成・共有に失敗した場合は、既存の「切り出し失敗」系ダイアログとトーンをそろえます。4枚へ収まらない場合は、理由と代替手段が分かる日本語を表示してください。
|
||||
|
||||
## 完了条件
|
||||
|
||||
- 新規ボタンから表紙+詳細画像が正しい順番で共有される
|
||||
- 全画像が厳密に1080×1440
|
||||
- 1件、2件、3件、4件以上、長文1件のレイアウトを確認している
|
||||
- 長文がCanvas外へはみ出さない
|
||||
- 空のタイトル、サブタイトル、更新日時でも壊れない
|
||||
- 地図画像のロード失敗時も白紙画像を共有せず、明確に失敗扱いまたは安全なフォールバックになる
|
||||
- 既存4種類の切り出し操作が維持される
|
||||
- TypeScript型チェックが通る
|
||||
- `git diff --check` が通る
|
||||
|
||||
## 検証
|
||||
|
||||
最低限、次を実行してください。
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
git diff --check
|
||||
```
|
||||
|
||||
注入スクリプトを変更した場合は、リポジトリ既存の生成手順も確認します。
|
||||
|
||||
```bash
|
||||
node scripts/generate-operation-info-userscript.mjs
|
||||
```
|
||||
|
||||
可能なら、Canvas描画を開発用に呼び出せる小さなテスト入口または純粋なページ分割関数を用意し、上記の件数・長文パターンを検証してください。本番UIへデバッグ操作を残さないでください。
|
||||
|
||||
## 完了報告に含めるもの
|
||||
|
||||
- 変更したファイル
|
||||
- 表紙・詳細ページの分割ルール
|
||||
- 4枚で収まらない場合の扱い
|
||||
- 実行した検証と結果
|
||||
- 端末で確認が必要な残件
|
||||
|
||||
@@ -72,6 +72,9 @@ export type CustomTrainData = {
|
||||
isThrough: boolean;
|
||||
platformNum: string | null;
|
||||
se?: string;
|
||||
isOrigin?: boolean;
|
||||
arrivalTime?: string;
|
||||
departureTime?: string;
|
||||
};
|
||||
|
||||
export type StationProps = {
|
||||
@@ -89,6 +92,9 @@ export type CustomTrainData = {
|
||||
lng: number;
|
||||
isSpot?: boolean;
|
||||
};
|
||||
|
||||
export type OriginalStationList = Record<string, StationProps[]>;
|
||||
|
||||
export type OperationLogs = {
|
||||
id: number;
|
||||
operation_id?: string;
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
hasNotificationSound,
|
||||
saveNotificationSound,
|
||||
scheduleLocationAnnouncements,
|
||||
type LocationAnnouncement,
|
||||
} from "expo-live-activity";
|
||||
import {
|
||||
hasVoicepeakConfiguration,
|
||||
loadVoicepeakSettings,
|
||||
requestVoicepeakSpeechBytes,
|
||||
} from "@/lib/voicepeak";
|
||||
import { encodeBase64 } from "@/lib/voicepeakAudioSource";
|
||||
import * as Notifications from "expo-notifications";
|
||||
|
||||
export type BackgroundRikkaTriggerSource =
|
||||
| "deviceLocation"
|
||||
| "trainPosition";
|
||||
|
||||
export const DEFAULT_BACKGROUND_RIKKA_TRIGGER_SOURCE: BackgroundRikkaTriggerSource =
|
||||
"deviceLocation";
|
||||
|
||||
export type BackgroundRikkaStation = {
|
||||
identifier: string;
|
||||
stationName: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
export type BackgroundRikkaPreparationResult = {
|
||||
scheduled: number;
|
||||
failedStations: string[];
|
||||
};
|
||||
|
||||
const MAX_LOCATION_ANNOUNCEMENTS = 20;
|
||||
const PREPARE_CONCURRENCY = 3;
|
||||
const DEFAULT_APPROACH_RADIUS_METERS = 800;
|
||||
|
||||
const notificationSoundFileName = (stationName: string) => {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < stationName.length; index++) {
|
||||
hash ^= stationName.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return `rikka-next-${(hash >>> 0).toString(36)}.wav`;
|
||||
};
|
||||
|
||||
const ensureNotificationSound = async (
|
||||
stationName: string,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const settings = await loadVoicepeakSettings();
|
||||
if (!hasVoicepeakConfiguration(settings)) {
|
||||
throw new Error("Voicepeak configuration is required");
|
||||
}
|
||||
|
||||
const soundFileName = notificationSoundFileName(stationName);
|
||||
if (!hasNotificationSound(soundFileName)) {
|
||||
const audioBytes = await requestVoicepeakSpeechBytes({
|
||||
text: `次は、${stationName}です。`,
|
||||
settings,
|
||||
signal,
|
||||
format: "wav",
|
||||
});
|
||||
await saveNotificationSound(soundFileName, encodeBase64(audioBytes));
|
||||
}
|
||||
return soundFileName;
|
||||
};
|
||||
|
||||
export const sendTrainPositionRikkaAnnouncement = async ({
|
||||
stationName,
|
||||
trainId,
|
||||
signal,
|
||||
}: {
|
||||
stationName: string;
|
||||
trainId: string;
|
||||
signal?: AbortSignal;
|
||||
}) => {
|
||||
const soundFileName = await ensureNotificationSound(stationName, signal);
|
||||
if (signal?.aborted) return;
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: "列車追従・りっかちゃん",
|
||||
body: `次は、${stationName}です。`,
|
||||
sound: soundFileName,
|
||||
data: {
|
||||
type: "train-follow-announcement",
|
||||
stationName,
|
||||
trainId,
|
||||
source: "trainPosition",
|
||||
},
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
};
|
||||
|
||||
export const prepareBackgroundRikkaAnnouncements = async ({
|
||||
trackingId,
|
||||
stations,
|
||||
signal,
|
||||
}: {
|
||||
trackingId: string;
|
||||
stations: BackgroundRikkaStation[];
|
||||
signal?: AbortSignal;
|
||||
}): Promise<BackgroundRikkaPreparationResult> => {
|
||||
const deduplicated = stations
|
||||
.filter(
|
||||
(station) =>
|
||||
station.stationName &&
|
||||
Number.isFinite(station.latitude) &&
|
||||
Number.isFinite(station.longitude)
|
||||
)
|
||||
.filter(
|
||||
(station, index, array) =>
|
||||
array.findIndex((candidate) => candidate.identifier === station.identifier) ===
|
||||
index
|
||||
)
|
||||
.slice(0, MAX_LOCATION_ANNOUNCEMENTS);
|
||||
|
||||
const prepared: Array<LocationAnnouncement | null> = new Array(
|
||||
deduplicated.length
|
||||
).fill(null);
|
||||
const failedStations: string[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
const prepareNext = async () => {
|
||||
while (cursor < deduplicated.length) {
|
||||
const index = cursor++;
|
||||
const station = deduplicated[index];
|
||||
if (signal?.aborted) return;
|
||||
|
||||
const soundFileName = notificationSoundFileName(station.stationName);
|
||||
try {
|
||||
await ensureNotificationSound(station.stationName, signal);
|
||||
|
||||
prepared[index] = {
|
||||
identifier: station.identifier,
|
||||
stationName: station.stationName,
|
||||
latitude: station.latitude,
|
||||
longitude: station.longitude,
|
||||
radiusMeters: DEFAULT_APPROACH_RADIUS_METERS,
|
||||
soundFileName,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!signal?.aborted) {
|
||||
failedStations.push(station.stationName);
|
||||
console.warn(
|
||||
`[BackgroundRikka] Failed to prepare ${station.stationName}`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
{ length: Math.min(PREPARE_CONCURRENCY, deduplicated.length) },
|
||||
prepareNext
|
||||
)
|
||||
);
|
||||
|
||||
if (signal?.aborted) {
|
||||
return { scheduled: 0, failedStations };
|
||||
}
|
||||
|
||||
const announcements = prepared.filter(
|
||||
(announcement): announcement is LocationAnnouncement => announcement != null
|
||||
);
|
||||
const scheduled =
|
||||
announcements.length > 0
|
||||
? await scheduleLocationAnnouncements(trackingId, announcements)
|
||||
: 0;
|
||||
|
||||
return { scheduled, failedStations };
|
||||
};
|
||||
+2
-39
@@ -2,6 +2,8 @@
|
||||
// Source: https://github.com/micolous/metrodroid (GPL-3.0)
|
||||
// Keys: stationId = ((areaCode)<<16) | ((lineCode)<<8) | stationCode
|
||||
// areaCode = data[15] >> 6 (from FeliCa history block byte 15)
|
||||
// The source contains historical duplicate IDs; this map intentionally keeps the last
|
||||
// record for each ID to preserve the original object last-record-wins lookup.
|
||||
|
||||
interface FelicaStationEntry { s: string; l: string; c: string }
|
||||
|
||||
@@ -61,7 +63,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x000147: { s: '清水', l: '東海道本', c: '東海旅客鉄道' },
|
||||
0x000149: { s: '草薙', l: '東海道本', c: '東海旅客鉄道' },
|
||||
0x00014b: { s: '東静岡', l: '東海道本', c: '東海旅客鉄道' },
|
||||
0x00014b: { s: '東静岡', l: '東海道本', c: '東海旅客鉄道' },
|
||||
0x00014c: { s: '静岡', l: '東海道本', c: '東海旅客鉄道' },
|
||||
0x00014e: { s: '安倍川', l: '東海道本', c: '東海旅客鉄道' },
|
||||
0x00014f: { s: '用宗', l: '東海道本', c: '東海旅客鉄道' },
|
||||
@@ -267,7 +268,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x000271: { s: '太子堂', l: '東北本', c: '東日本旅客鉄道' },
|
||||
0x000272: { s: '長町', l: '東北本', c: '東日本旅客鉄道' },
|
||||
0x000273: { s: '仙台', l: '東北本', c: '東日本旅客鉄道' },
|
||||
0x000273: { s: '仙台', l: '東北本', c: '東日本旅客鉄道' },
|
||||
0x000275: { s: '東仙台', l: '東北本', c: '東日本旅客鉄道' },
|
||||
0x000276: { s: '岩切', l: '東北本', c: '東日本旅客鉄道' },
|
||||
0x000277: { s: '陸前山王', l: '東北本', c: '東日本旅客鉄道' },
|
||||
@@ -665,9 +665,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x00062c: { s: '大野城', l: '鹿児島本', c: '九州旅客鉄道' },
|
||||
0x00062d: { s: '水城', l: '鹿児島本', c: '九州旅客鉄道' },
|
||||
0x00062e: { s: '都府楼南', l: '鹿児島本', c: '九州旅客鉄道' },
|
||||
0x00062f: { s: '二日市', l: '鹿児島本', c: '九州旅客鉄道' },
|
||||
0x00062f: { s: '都府楼南', l: '鹿児島本', c: '九州旅客鉄道' },
|
||||
0x000630: { s: '二日市', l: '鹿児島本', c: '九州旅客鉄道' },
|
||||
0x000630: { s: '天拝山', l: '鹿児島本', c: '九州旅客鉄道' },
|
||||
0x000631: { s: '天拝山', l: '鹿児島本', c: '九州旅客鉄道' },
|
||||
0x000632: { s: '原田', l: '鹿児島本', c: '九州旅客鉄道' },
|
||||
@@ -725,7 +723,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x00071a: { s: '三毛門', l: '日豊本', c: '九州旅客鉄道' },
|
||||
0x00071b: { s: '吉富', l: '日豊本', c: '九州旅客鉄道' },
|
||||
0x00071c: { s: '中津', l: '日豊本', c: '九州旅客鉄道' },
|
||||
0x000728: { s: '中津', l: '日豊本', c: '九州旅客鉄道' },
|
||||
0x000728: { s: '宇佐', l: '日豊本', c: '九州旅客鉄道' },
|
||||
0x00072a: { s: '西屋敷', l: '日豊本', c: '九州旅客鉄道' },
|
||||
0x00073b: { s: '別府', l: '日豊本', c: '九州旅客鉄道' },
|
||||
@@ -1113,9 +1110,7 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x00104c: { s: '高野川', l: '予讃', c: '四国旅客鉄道' },
|
||||
0x001058: { s: '五郎', l: '予讃', c: '四国旅客鉄道' },
|
||||
0x001059: { s: '伊予大洲', l: '予讃', c: '四国旅客鉄道' },
|
||||
0x001081: { s: '御免', l: 'ごめんなはり', c: '土佐くろしお鉄道' },
|
||||
0x001081: { s: '後免', l: '阿佐', c: '土佐くろしお鉄道' },
|
||||
0x001083: { s: '後免町', l: '阿佐', c: '土佐くろしお鉄道' },
|
||||
0x001083: { s: '御免町', l: 'ごめんなはり', c: '土佐くろしお鉄道' },
|
||||
0x001102: { s: '金蔵寺', l: '土讃', c: '四国旅客鉄道' },
|
||||
0x001103: { s: '善通寺', l: '土讃', c: '四国旅客鉄道' },
|
||||
@@ -1123,7 +1118,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x00110c: { s: '佃', l: '土讃', c: '四国旅客鉄道' },
|
||||
0x00110d: { s: '阿波池田', l: '土讃', c: '四国旅客鉄道' },
|
||||
0x001113: { s: '大歩危', l: '土讃', c: '四国旅客鉄道' },
|
||||
0x001124: { s: '御免', l: '土讃', c: '四国旅客鉄道' },
|
||||
0x001124: { s: '後免', l: '土讃', c: '四国旅客鉄道' },
|
||||
0x00112a: { s: '高知', l: '土讃', c: '四国旅客鉄道' },
|
||||
0x00112b: { s: '入明', l: '土讃', c: '四国旅客鉄道' },
|
||||
@@ -1649,7 +1643,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x002984: { s: '岩手和井内', l: '岩泉', c: '東日本旅客鉄道' },
|
||||
0x002986: { s: '押角', l: '岩泉', c: '東日本旅客鉄道' },
|
||||
0x002a0c: { s: '新川崎', l: '横須賀', c: '東日本旅客鉄道' },
|
||||
0x002a0d: { s: '武蔵小杉', l: '横須賀', c: '東日本旅客鉄道' },
|
||||
0x002a0d: { s: '新川崎', l: '横須賀', c: '東日本旅客鉄道' },
|
||||
0x002a0e: { s: '西大井', l: '横須賀', c: '東日本旅客鉄道' },
|
||||
0x002a15: { s: '新日本橋', l: '総武本', c: '東日本旅客鉄道' },
|
||||
@@ -1671,7 +1664,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x002ada: { s: '登別', l: '室蘭本', c: '北海道旅客鉄道' },
|
||||
0x002ae0: { s: '白老', l: '室蘭本', c: '北海道旅客鉄道' },
|
||||
0x002ae3: { s: '錦岡', l: '室蘭本', c: '北海道旅客鉄道' },
|
||||
0x002ae3: { s: '錦岡', l: '室蘭本', c: '北海道旅客鉄道' },
|
||||
0x002ae5: { s: '糸井', l: '室蘭本', c: '北海道旅客鉄道' },
|
||||
0x002ae9: { s: '苫小牧', l: '室蘭本', c: '北海道旅客鉄道' },
|
||||
0x002aeb: { s: '沼ノ端', l: '室蘭本', c: '北海道旅客鉄道' },
|
||||
@@ -2281,7 +2273,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x00524a: { s: '三角', l: '三角', c: '九州旅客鉄道' },
|
||||
0x005318: { s: '太田部', l: '小海', c: '東日本旅客鉄道' },
|
||||
0x005319: { s: '中込', l: '小海', c: '東日本旅客鉄道' },
|
||||
0x00531a: { s: '滑津', l: '小海', c: '東日本旅客鉄道' },
|
||||
0x00531a: { s: '中込', l: '小海', c: '東日本旅客鉄道' },
|
||||
0x00531b: { s: '北中込', l: '小海', c: '東日本旅客鉄道' },
|
||||
0x00531c: { s: '岩村田', l: '小海', c: '東日本旅客鉄道' },
|
||||
@@ -2291,7 +2282,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x005352: { s: '人吉', l: '肥薩', c: '九州旅客鉄道' },
|
||||
0x00540c: { s: '戸狩野沢温泉', l: '飯山', c: '東日本旅客鉄道' },
|
||||
0x005415: { s: '森宮野原', l: '飯山', c: '東日本旅客鉄道' },
|
||||
0x005415: { s: '森宮野原', l: '飯山', c: '東日本旅客鉄道' },
|
||||
0x00541f: { s: '十日町', l: '飯山', c: '東日本旅客鉄道' },
|
||||
0x005420: { s: '魚沼中条', l: '飯山', c: '東日本旅客鉄道' },
|
||||
0x005515: { s: '吉田', l: '越後', c: '東日本旅客鉄道' },
|
||||
@@ -2715,7 +2705,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x007403: { s: '播磨高岡', l: '姫新', c: '西日本旅客鉄道' },
|
||||
0x007404: { s: '余部', l: '姫新', c: '西日本旅客鉄道' },
|
||||
0x007407: { s: '本竜野', l: '姫新', c: '西日本旅客鉄道' },
|
||||
0x007408: { s: '東觜崎', l: '姫新線', c: '西日本旅客鉄道' },
|
||||
0x007408: { s: '東觜崎', l: '姫新', c: '西日本旅客鉄道' },
|
||||
0x007409: { s: '播磨新宮', l: '姫新', c: '西日本旅客鉄道' },
|
||||
0x007410: { s: '佐用', l: '姫新', c: '西日本旅客鉄道' },
|
||||
@@ -3453,7 +3442,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x00c708: { s: '京王多摩センター', l: '相模原', c: '京王電鉄' },
|
||||
0x00c709: { s: '京王堀之内', l: '相模原', c: '京王電鉄' },
|
||||
0x00c70a: { s: '南大沢', l: '相模原', c: '京王電鉄' },
|
||||
0x00c70a: { s: '南大沢', l: '相模原', c: '京王電鉄' },
|
||||
0x00c70b: { s: '多摩境', l: '相模原', c: '京王電鉄' },
|
||||
0x00c70c: { s: '橋本', l: '相模原', c: '京王電鉄' },
|
||||
0x00c801: { s: '北野', l: '高尾', c: '京王電鉄' },
|
||||
@@ -3596,7 +3584,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x00d403: { s: '新高島', l: 'みなとみらい', c: '横浜高速鉄道' },
|
||||
0x00d405: { s: 'みなとみらい', l: 'みなとみらい', c: '横浜高速鉄道' },
|
||||
0x00d407: { s: '馬車道', l: 'みなとみらい', c: '横浜高速鉄道' },
|
||||
0x00d409: { s: '日本大通', l: 'みなとみらい', c: '横浜高速鉄道' },
|
||||
0x00d409: { s: '日本大通り', l: 'みなとみらい', c: '横浜高速鉄道' },
|
||||
0x00d40b: { s: '元町・中華街', l: 'みなとみらい', c: '横浜高速鉄道' },
|
||||
0x00d501: { s: '泉岳寺', l: '本', c: '京浜急行電鉄' },
|
||||
@@ -3654,14 +3641,8 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x00d603: { s: '大鳥居', l: '空港', c: '京浜急行電鉄' },
|
||||
0x00d604: { s: '穴守稲荷', l: '空港', c: '京浜急行電鉄' },
|
||||
0x00d605: { s: '天空橋', l: '空港', c: '京浜急行電鉄' },
|
||||
0x00d606: { s: '国際ターミナル駅(仮称)', l: '空港', c: '京浜急行電鉄' },
|
||||
0x00d606: { s: '羽田空港国際線ターミナル', l: '空港', c: '京浜急行電鉄' },
|
||||
0x00d607: { s: '羽田空港', l: '空港', c: '京浜急行電鉄' },
|
||||
0x00d607: { s: '羽田空港', l: '空港', c: '京浜急行電鉄' },
|
||||
0x00d607: { s: '羽田空港', l: '空港', c: '京浜急行電鉄' },
|
||||
0x00d607: { s: '羽田空港国内線ターミナル', l: '空港', c: '京浜急行電鉄' },
|
||||
0x00d607: { s: '羽田空港', l: '空港', c: '京浜急行' },
|
||||
0x00d607: { s: '羽田空港', l: '空港', c: '京浜急行電鉄' },
|
||||
0x00d701: { s: '京急川崎', l: '大師', c: '京浜急行電鉄' },
|
||||
0x00d702: { s: '港町', l: '大師', c: '京浜急行電鉄' },
|
||||
0x00d703: { s: '鈴木町', l: '大師', c: '京浜急行電鉄' },
|
||||
@@ -4111,7 +4092,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x00fa07: { s: '整備場', l: '東京モノレール羽田', c: '東京モノレール' },
|
||||
0x00fa08: { s: '羽田', l: '東京モノレール羽田', c: '東京モノレール' },
|
||||
0x00fa09: { s: '天空橋', l: '東京モノレール羽田', c: '東京モノレール' },
|
||||
0x00fa0a: { s: '国際線ターミナルビル', l: '東京モノレール羽田', c: '東京モノレール' },
|
||||
0x00fa0a: { s: '羽田空港国際線ビル', l: '東京モノレール羽田', c: '東京モノレール' },
|
||||
0x00fa0b: { s: '新整備場', l: '東京モノレール羽田', c: '東京モノレール' },
|
||||
0x00fa0c: { s: '羽田空港第1ビル', l: '東京モノレール羽田', c: '東京モノレール' },
|
||||
@@ -4823,7 +4803,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x02933e: { s: '元町', l: '本', c: '阪神電気鉄道' },
|
||||
0x029401: { s: '大阪難波', l: '阪神なんば', c: '阪神電気鉄道' },
|
||||
0x029403: { s: '桜川', l: '阪神なんば', c: '阪神電気鉄道' },
|
||||
0x029404: { s: '桜川', l: '阪神なんば', c: '阪神電気鉄道' },
|
||||
0x029404: { s: 'ドーム前', l: '阪神なんば', c: '阪神電気鉄道' },
|
||||
0x029405: { s: '九条', l: '阪神なんば', c: '阪神電気鉄道' },
|
||||
0x029407: { s: '西九条', l: '西大阪', c: '阪神電気鉄道' },
|
||||
@@ -5122,12 +5101,9 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x02c54e: { s: '鳥羽街道', l: '京阪本', c: '京阪電気鉄道' },
|
||||
0x02c550: { s: '東福寺', l: '京阪本', c: '京阪電気鉄道' },
|
||||
0x02c552: { s: '七条', l: '京阪本', c: '京阪電気鉄道' },
|
||||
0x02c554: { s: '五条', l: '京阪本', c: '京阪電気鉄道' },
|
||||
0x02c554: { s: '清水五条', l: '京阪本', c: '京阪電気鉄道' },
|
||||
0x02c556: { s: '祇園四条', l: '京阪本', c: '京阪電気鉄道' },
|
||||
0x02c556: { s: '四条', l: '京阪本', c: '京阪電気鉄道' },
|
||||
0x02c558: { s: '三条', l: '京阪本', c: '京阪電気鉄道' },
|
||||
0x02c55a: { s: '丸太町', l: '鴨東', c: '京阪電気鉄道' },
|
||||
0x02c55a: { s: '神宮丸太町', l: '鴨東', c: '京阪電気鉄道' },
|
||||
0x02c55c: { s: '出町柳', l: '鴨東', c: '京阪電気鉄道' },
|
||||
0x02c5c2: { s: '八幡市', l: '京阪鋼索', c: '京阪電気鉄道' },
|
||||
@@ -5336,12 +5312,8 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x02e603: { s: '近鉄新庄', l: '御所', c: '近畿日本鉄道' },
|
||||
0x02e604: { s: '忍海', l: '御所', c: '近畿日本鉄道' },
|
||||
0x02e605: { s: '近鉄御所', l: '御所', c: '近畿日本鉄道' },
|
||||
0x02e701: { s: '近鉄難波', l: '難波', c: '近畿日本鉄道' },
|
||||
0x02e701: { s: '大阪難波', l: '難波', c: '近畿日本鉄道' },
|
||||
0x02e701: { s: '大阪難波', l: '難波', c: '近畿日本鉄道' },
|
||||
0x02e702: { s: '近鉄日本橋', l: '難波', c: '近畿日本鉄道' },
|
||||
0x02e703: { s: '上本町', l: '大阪', c: '近畿日本鉄道' },
|
||||
0x02e703: { s: '大阪上本町', l: '大阪', c: '近畿日本鉄道' },
|
||||
0x02e703: { s: '大阪上本町', l: '大阪', c: '近畿日本鉄道' },
|
||||
0x02e704: { s: '鶴橋', l: '大阪', c: '近畿日本鉄道' },
|
||||
0x02e706: { s: '今里', l: '大阪', c: '近畿日本鉄道' },
|
||||
@@ -5495,7 +5467,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x02ef16: { s: '桑名', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef17: { s: '益生', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef19: { s: '伊勢朝日', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef1b: { s: '富洲原', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef1b: { s: '川越富洲原', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef1d: { s: '近鉄富田', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef1f: { s: '霞ヶ浦', l: '名古屋', c: '近畿日本鉄道' },
|
||||
@@ -5512,7 +5483,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x02ef2c: { s: '伊勢若松', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef2e: { s: '千代崎', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef30: { s: '白子', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef30: { s: '白子', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef31: { s: '鼓ヶ浦', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef33: { s: '磯山', l: '名古屋', c: '近畿日本鉄道' },
|
||||
0x02ef35: { s: '千里', l: '名古屋', c: '近畿日本鉄道' },
|
||||
@@ -5573,7 +5543,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x02f303: { s: '馬道 廃止', l: '北勢', c: '近畿日本鉄道' },
|
||||
0x02f304: { s: '西別所 廃止', l: '北勢', c: '近畿日本鉄道' },
|
||||
0x02f305: { s: '蓮花寺 廃止', l: '北勢', c: '近畿日本鉄道' },
|
||||
0x02f305: { s: '蓮花寺 廃止', l: '北勢', c: '近畿日本鉄道' },
|
||||
0x02f306: { s: '在良 廃止', l: '北勢', c: '近畿日本鉄道' },
|
||||
0x02f307: { s: '坂井橋 廃止', l: '北勢', c: '近畿日本鉄道' },
|
||||
0x02f30b: { s: '六把野 廃止', l: '北勢', c: '近畿日本鉄道' },
|
||||
@@ -5629,15 +5598,12 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x02ffb7: { s: '貿易センター', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffb9: { s: 'ポートターミナル', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffbb: { s: '中公園', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffbd: { s: '市民病院前', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffbd: { s: 'みなとじま', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffbf: { s: '市民広場', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffc1: { s: '南公園', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffc3: { s: '中埠頭', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffc5: { s: '北埠頭', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffc7: { s: '先端医療センター前', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffc7: { s: '医療センター', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffc9: { s: '京コンピュータ前', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffc9: { s: 'ポートアイランド南', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x02ffcb: { s: '神戸空港', l: 'ポートアイランド', c: '神戸新交通' },
|
||||
0x032841: { s: '川西', l: '錦川清流', c: '錦川鉄道' },
|
||||
@@ -5722,7 +5688,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x03be19: { s: '城北', l: 'アストラムライン', c: '広島高速交通' },
|
||||
0x03be1d: { s: '白島', l: 'アストラムライン', c: '広島高速交通' },
|
||||
0x03be1f: { s: '牛田', l: 'アストラムライン', c: '広島高速交通' },
|
||||
0x03be21: { s: '不動院前', l: 'アストラムライン', c: '広島' },
|
||||
0x03be21: { s: '不動院前', l: 'アストラムライン', c: '広島高速交通' },
|
||||
0x03be23: { s: '祇園新橋北', l: 'アストラムライン', c: '広島高速交通' },
|
||||
0x03be25: { s: '西原', l: 'アストラムライン', c: '広島高速交通' },
|
||||
@@ -5832,7 +5797,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x03d64d: { s: '西鉄千早', l: '貝塚', c: '西日本鉄道' },
|
||||
0x03d64f: { s: '名島', l: '貝塚', c: '西日本鉄道' },
|
||||
0x03d651: { s: '貝塚', l: '貝塚', c: '西日本鉄道' },
|
||||
0x03d765: { s: '西鉄福岡(天神)', l: '天神大牟田線', c: '西日本鉄道' },
|
||||
0x03d765: { s: '西鉄福岡(天神)', l: '天神大牟田', c: '西日本鉄道' },
|
||||
0x03d767: { s: '薬院', l: '天神大牟田', c: '西日本鉄道' },
|
||||
0x03d769: { s: '西鉄平尾', l: '天神大牟田', c: '西日本鉄道' },
|
||||
@@ -5846,7 +5810,6 @@ export const FELICA_STATION_MAP: Record<number, FelicaStationEntry> = {
|
||||
0x03d777: { s: '下大利', l: '天神大牟田', c: '西日本鉄道' },
|
||||
0x03d779: { s: '都府楼前', l: '天神大牟田', c: '西日本鉄道' },
|
||||
0x03d77b: { s: '西鉄二日市', l: '天神大牟田', c: '西日本鉄道' },
|
||||
0x03d77c: { s: '二日市南', l: '天神大牟田', c: '西日本鉄道' },
|
||||
0x03d77c: { s: '紫', l: '天神大牟田', c: '西日本鉄道' },
|
||||
0x03d77d: { s: '朝倉街道', l: '天神大牟田', c: '西日本鉄道' },
|
||||
0x03d77f: { s: '桜台', l: '天神大牟田', c: '西日本鉄道' },
|
||||
|
||||
+14
-49
@@ -1,53 +1,18 @@
|
||||
import { trainTypeID } from "@/lib/CommonTypes";
|
||||
import {
|
||||
getTrainType,
|
||||
type TrainTypeFontFamily,
|
||||
} from "@/lib/getTrainType";
|
||||
|
||||
type types = (
|
||||
type: trainTypeID,
|
||||
id: string,
|
||||
) => [string, TrainTypeFontFamily | undefined, boolean];
|
||||
|
||||
type types = (types: trainTypeID, id: string) => [string, boolean, boolean];
|
||||
export const getStringConfig: types = (type, id) => {
|
||||
switch (type) {
|
||||
case "Normal":
|
||||
return ["普通", true, false];
|
||||
case "OneMan":
|
||||
return ["普通", true, true];
|
||||
case "Rapid":
|
||||
return ["快速", true, false];
|
||||
case "OneManRapid":
|
||||
return ["快速", true, true];
|
||||
case "LTDEXP":
|
||||
return ["特急", true, false];
|
||||
case "NightLTDEXP":
|
||||
return ["特急", true, false];
|
||||
case "SPCL":
|
||||
return ["臨時", true, false];
|
||||
case "SPCL_Normal":
|
||||
return ["臨時", true, false];
|
||||
case "SPCL_Rapid":
|
||||
return ["臨時快速", true, false];
|
||||
case "SPCL_EXP":
|
||||
return ["臨時特急", true, false];
|
||||
case "Party":
|
||||
return ["団体", true, false];
|
||||
case "Freight":
|
||||
return ["貨物", false, false];
|
||||
case "Forwarding":
|
||||
return ["回送", false, false];
|
||||
case "Trial":
|
||||
return ["試運転", false, false];
|
||||
case "Construction":
|
||||
return ["工事", false, false];
|
||||
case "FreightForwarding":
|
||||
return ["単機回送", false, false];
|
||||
case "Other":
|
||||
switch (true) {
|
||||
case !!id.includes("T"):
|
||||
return ["単機回送", false, false];
|
||||
case !!id.includes("R"):
|
||||
case !!id.includes("E"):
|
||||
case !!id.includes("L"):
|
||||
case !!id.includes("A"):
|
||||
case !!id.includes("B"):
|
||||
return ["回送", false, false];
|
||||
case !!id.includes("H"):
|
||||
return ["試運転", false, false];
|
||||
}
|
||||
return ["", false, false];
|
||||
}
|
||||
const { shortName, fontFamily, isOneMan } = getTrainType({ type, id });
|
||||
const typeString =
|
||||
type === "Other" && shortName === "その他" ? "" : shortName;
|
||||
|
||||
return [typeString, fontFamily, isOneMan];
|
||||
};
|
||||
|
||||
+21
-20
@@ -29,6 +29,7 @@ type trainTypeString =
|
||||
| "試運転"
|
||||
| "工事"
|
||||
| "その他";
|
||||
export type TrainTypeFontFamily = "JR-Nishi" | "JR-WEST-PLUS";
|
||||
type trainTypeDataString = "rapid" | "express" | "normal" | "notService";
|
||||
type getTrainType = (e: {
|
||||
type: trainTypeID;
|
||||
@@ -38,7 +39,7 @@ type getTrainType = (e: {
|
||||
color: colorString;
|
||||
name: trainTypeString;
|
||||
shortName: string;
|
||||
fontAvailable: boolean;
|
||||
fontFamily: TrainTypeFontFamily | undefined;
|
||||
isOneMan: boolean;
|
||||
data: trainTypeDataString;
|
||||
};
|
||||
@@ -49,7 +50,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: whiteMode ? "#333333ff" : "white",
|
||||
name: "普通列車",
|
||||
shortName: "普通",
|
||||
fontAvailable: true,
|
||||
fontFamily: "JR-Nishi",
|
||||
isOneMan: false,
|
||||
data: "normal",
|
||||
};
|
||||
@@ -58,7 +59,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: whiteMode ? "#333333ff" : "white",
|
||||
name: "普通列車(ワンマン)",
|
||||
shortName: "普通",
|
||||
fontAvailable: true,
|
||||
fontFamily: "JR-Nishi",
|
||||
isOneMan: true,
|
||||
data: "normal",
|
||||
};
|
||||
@@ -67,7 +68,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: whiteMode ? "#00a0bdff" : "aqua",
|
||||
name: "快速",
|
||||
shortName: "快速",
|
||||
fontAvailable: true,
|
||||
fontFamily: "JR-Nishi",
|
||||
isOneMan: false,
|
||||
data: "rapid",
|
||||
};
|
||||
@@ -76,7 +77,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: whiteMode ? "#00a0bdff" : "aqua",
|
||||
name: "快速",
|
||||
shortName: "快速",
|
||||
fontAvailable: true,
|
||||
fontFamily: "JR-Nishi",
|
||||
isOneMan: true,
|
||||
data: "rapid",
|
||||
};
|
||||
@@ -85,7 +86,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#ee424dff",
|
||||
name: "特急",
|
||||
shortName: "特急",
|
||||
fontAvailable: true,
|
||||
fontFamily: "JR-Nishi",
|
||||
isOneMan: false,
|
||||
data: "express",
|
||||
};
|
||||
@@ -94,7 +95,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: whiteMode ? "#e000b0ff" : "pink",
|
||||
name: "寝台特急",
|
||||
shortName: "特急",
|
||||
fontAvailable: true,
|
||||
fontFamily: "JR-Nishi",
|
||||
isOneMan: false,
|
||||
data: "express",
|
||||
};
|
||||
@@ -104,7 +105,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#297bff",
|
||||
name: "臨時",
|
||||
shortName: "臨時",
|
||||
fontAvailable: true,
|
||||
fontFamily: "JR-Nishi",
|
||||
isOneMan: false,
|
||||
data: "normal",
|
||||
};
|
||||
@@ -113,7 +114,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#297bff",
|
||||
name: "臨時快速",
|
||||
shortName: "臨時快速",
|
||||
fontAvailable: true,
|
||||
fontFamily: "JR-Nishi",
|
||||
isOneMan: false,
|
||||
data: "normal",
|
||||
};
|
||||
@@ -122,7 +123,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#297bff",
|
||||
name: "臨時特急",
|
||||
shortName: "臨時特急",
|
||||
fontAvailable: true,
|
||||
fontFamily: "JR-Nishi",
|
||||
isOneMan: false,
|
||||
data: "normal",
|
||||
};
|
||||
@@ -131,7 +132,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#ff7300ff",
|
||||
name: "団体",
|
||||
shortName: "団体",
|
||||
fontAvailable: true,
|
||||
fontFamily: "JR-Nishi",
|
||||
isOneMan: false,
|
||||
data: "normal",
|
||||
};
|
||||
@@ -140,7 +141,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#007488ff",
|
||||
name: "貨物",
|
||||
shortName: "貨物",
|
||||
fontAvailable: false,
|
||||
fontFamily: undefined,
|
||||
isOneMan: false,
|
||||
data: "notService",
|
||||
};
|
||||
@@ -149,7 +150,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#5f5f5fff",
|
||||
name: "回送",
|
||||
shortName: "回送",
|
||||
fontAvailable: false,
|
||||
fontFamily: "JR-WEST-PLUS",
|
||||
isOneMan: false,
|
||||
data: "notService",
|
||||
};
|
||||
@@ -158,7 +159,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#5f5f5fff",
|
||||
name: "試運転",
|
||||
shortName: "試運転",
|
||||
fontAvailable: false,
|
||||
fontFamily: "JR-WEST-PLUS",
|
||||
isOneMan: false,
|
||||
data: "notService",
|
||||
};
|
||||
@@ -167,7 +168,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#5f5f5fff",
|
||||
name: "工事",
|
||||
shortName: "工事",
|
||||
fontAvailable: false,
|
||||
fontFamily: undefined,
|
||||
isOneMan: false,
|
||||
data: "notService",
|
||||
};
|
||||
@@ -176,7 +177,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#5f5f5fff",
|
||||
name: "単機回送",
|
||||
shortName: "単機回送",
|
||||
fontAvailable: false,
|
||||
fontFamily: undefined,
|
||||
isOneMan: false,
|
||||
data: "notService",
|
||||
};
|
||||
@@ -189,7 +190,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#5f5f5fff",
|
||||
name: "単機回送",
|
||||
shortName: "単機回送",
|
||||
fontAvailable: false,
|
||||
fontFamily: undefined,
|
||||
isOneMan: false,
|
||||
data: "notService",
|
||||
};
|
||||
@@ -202,7 +203,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#5f5f5fff",
|
||||
name: "回送",
|
||||
shortName: "回送",
|
||||
fontAvailable: false,
|
||||
fontFamily: "JR-WEST-PLUS",
|
||||
isOneMan: false,
|
||||
data: "notService",
|
||||
};
|
||||
@@ -211,7 +212,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: "#5f5f5fff",
|
||||
name: "試運転",
|
||||
shortName: "試運転",
|
||||
fontAvailable: false,
|
||||
fontFamily: "JR-WEST-PLUS",
|
||||
isOneMan: false,
|
||||
data: "notService",
|
||||
};
|
||||
@@ -221,7 +222,7 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
color: whiteMode ? "#333333ff" : "white",
|
||||
name: "その他",
|
||||
shortName: "その他",
|
||||
fontAvailable: false,
|
||||
fontFamily: undefined,
|
||||
isOneMan: false,
|
||||
data: "normal",
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
+44
-12
@@ -1,4 +1,5 @@
|
||||
import dayjs from "dayjs";
|
||||
import { checkDuplicateTrainData } from "@/lib/checkDuplicateTrainData";
|
||||
import { trainDataType, trainPosition } from "@/lib/trainPositionTextArray";
|
||||
import { eachTrainDiagramType, StationProps } from "@/lib/CommonTypes";
|
||||
import betweenData from "@/assets/originData/between";
|
||||
@@ -6,12 +7,15 @@ type trainDataProps = {
|
||||
d: eachTrainDiagramType;
|
||||
currentTrain: trainDataType[];
|
||||
station: StationProps[];
|
||||
stationList: StationProps[][];
|
||||
now?: string | null;
|
||||
};
|
||||
export const trainTimeFiltering: (x: trainDataProps) => boolean = (props) => {
|
||||
const { d, currentTrain, station } = props;
|
||||
const { d, currentTrain, station, stationList, now } = props;
|
||||
const baseTime = 2; // 何時間以内の列車を表示するか
|
||||
if (currentTrain.filter((t) => t.num == d.train).length == 0) {
|
||||
const date = dayjs();
|
||||
const currentTrainMatches = currentTrain.filter((t) => t.num == d.train);
|
||||
if (currentTrainMatches.length == 0) {
|
||||
const date = now ? dayjs(now) : dayjs();
|
||||
const trainTime = date
|
||||
.hour(parseInt(d.time.split(":")[0]))
|
||||
.minute(parseInt(d.time.split(":")[1]));
|
||||
@@ -23,7 +27,10 @@ export const trainTimeFiltering: (x: trainDataProps) => boolean = (props) => {
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
const Pos = trainPosition(currentTrain.filter((t) => t.num == d.train)[0]);
|
||||
const currentTrainData =
|
||||
checkDuplicateTrainData(currentTrainMatches, stationList) ??
|
||||
currentTrainMatches[0];
|
||||
const Pos = trainPosition(currentTrainData);
|
||||
let nextPos = "";
|
||||
let PrePos = "";
|
||||
//
|
||||
@@ -65,9 +72,9 @@ export const trainTimeFiltering: (x: trainDataProps) => boolean = (props) => {
|
||||
break;
|
||||
}
|
||||
const [h, m] = d.time.split(":");
|
||||
const delayData = currentTrain.filter((t) => t.num == d.train)[0].delay;
|
||||
const delayData = currentTrainData.delay;
|
||||
let delay = delayData === "入線" ? 0 : delayData;
|
||||
const date = dayjs();
|
||||
const date = now ? dayjs(now) : dayjs();
|
||||
const IntH = parseInt(h);
|
||||
const IntM = parseInt(m);
|
||||
const currentHour = date.hour();
|
||||
@@ -96,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: "",
|
||||
@@ -103,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;
|
||||
@@ -115,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);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const WRAPPABLE_TYPE_LENGTH = 4;
|
||||
const MIN_LONG_TRAIN_NAME_LENGTH = 8;
|
||||
|
||||
const countCharacters = (value: string) => Array.from(value).length;
|
||||
|
||||
const normalizeTrainName = (trainName: string) => trainName.replace(/\s+/g, "");
|
||||
|
||||
export const shouldWrapTrainType = (
|
||||
typeName: string,
|
||||
trainName: string,
|
||||
): boolean =>
|
||||
countCharacters(typeName) === WRAPPABLE_TYPE_LENGTH &&
|
||||
countCharacters(normalizeTrainName(trainName)) >= MIN_LONG_TRAIN_NAME_LENGTH;
|
||||
|
||||
export const formatWrappedTrainType = (typeName: string): string => {
|
||||
const chars = Array.from(typeName);
|
||||
|
||||
if (chars.length !== WRAPPABLE_TYPE_LENGTH) {
|
||||
return typeName;
|
||||
}
|
||||
|
||||
return `${chars.slice(0, 2).join("")}\n${chars.slice(2).join("")}`;
|
||||
};
|
||||
@@ -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"))
|
||||
);
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -192,6 +192,7 @@ export const injectJavascriptData = ({
|
||||
// データ変数の宣言
|
||||
let stationList = {};
|
||||
let trainDataList = [];
|
||||
let trainDataListReady = false;
|
||||
let operationList = [];
|
||||
let trainDiagramData2 = {};
|
||||
let probremsData = [];
|
||||
@@ -259,11 +260,13 @@ export const injectJavascriptData = ({
|
||||
const DatalistUpdate = () => {
|
||||
fetch("${API_ENDPOINTS.TRAIN_DATA_API}").then(r => r.json())
|
||||
.then(data => {
|
||||
if (hasChanged('trainDataList', data.data)) {
|
||||
trainDataList = data.data;
|
||||
_wcache.set('trainDataList', trainDataList);
|
||||
setReload();
|
||||
}
|
||||
const nextTrainDataList = Array.isArray(data?.data) ? data.data : null;
|
||||
if (!nextTrainDataList) return;
|
||||
trainDataListReady = true;
|
||||
if (!hasChanged('trainDataList', nextTrainDataList)) return;
|
||||
trainDataList = nextTrainDataList;
|
||||
_wcache.set('trainDataList', trainDataList);
|
||||
setReload();
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { setTimeout(DatalistUpdate, ${INTERVALS.DELAY_UPDATE}); });
|
||||
@@ -362,8 +365,12 @@ export const injectJavascriptData = ({
|
||||
Object.keys(_hashes).forEach(k => { _hashes[k] = null; });
|
||||
Promise.allSettled([
|
||||
fetch("${API_ENDPOINTS.TRAIN_DATA_API}").then(r => r.json()).then(data => {
|
||||
trainDataList = data.data ?? trainDataList;
|
||||
_hashes['trainDataList'] = JSON.stringify(trainDataList);
|
||||
if (Array.isArray(data?.data)) {
|
||||
trainDataList = data.data;
|
||||
trainDataListReady = true;
|
||||
_hashes['trainDataList'] = JSON.stringify(trainDataList);
|
||||
_wcache.set('trainDataList', trainDataList);
|
||||
}
|
||||
}).catch(() => {}),
|
||||
fetch("${API_ENDPOINTS.OPERATION_LOGS}").then(r => r.json()).then(data => {
|
||||
const source = Array.isArray(data?.data) ? data.data : [];
|
||||
@@ -406,6 +413,7 @@ export const injectJavascriptData = ({
|
||||
if (_c0.operationList) { operationList = _c0.operationList; _hashes['operationList'] = JSON.stringify(_c0.operationList); anyHit = true; }
|
||||
if (_c0.probremsData) { probremsData = _c0.probremsData; _hashes['probremsData'] = JSON.stringify(_c0.probremsData); anyHit = true; }
|
||||
if (_c0.trainDataList) { trainDataList = _c0.trainDataList; _hashes['trainDataList'] = JSON.stringify(_c0.trainDataList); anyHit = true; }
|
||||
if (_c0.trainDataList) trainDataListReady = true;
|
||||
if (_c0.trainDiagramData2) { trainDiagramData2 = _c0.trainDiagramData2; _hashes['trainDiagramData2'] = JSON.stringify(_c0.trainDiagramData2); anyHit = true; }
|
||||
if (_c0.unyohubData) { unyohubData = _c0.unyohubData; anyHit = true; }
|
||||
if (_c0.elesiteData) { elesiteData = _c0.elesiteData; anyHit = true; }
|
||||
@@ -443,7 +451,9 @@ export const injectJavascriptData = ({
|
||||
// TRAIN_DATA_API: 0.84s/1MB, DIAGRAM_TODAY: 0.27s/522KB
|
||||
Promise.allSettled([
|
||||
fetch("${API_ENDPOINTS.TRAIN_DATA_API}").then(r => r.json()).then(data => {
|
||||
trainDataList = data.data ?? [];
|
||||
if (!Array.isArray(data?.data)) return;
|
||||
trainDataList = data.data;
|
||||
trainDataListReady = true;
|
||||
_hashes['trainDataList'] = JSON.stringify(trainDataList);
|
||||
_wcache.set('trainDataList', trainDataList);
|
||||
}).catch(() => {}),
|
||||
@@ -696,6 +706,21 @@ export const injectJavascriptData = ({
|
||||
let isSeason = false;
|
||||
let TrainNumberOverride;
|
||||
let optionalText = "";
|
||||
const registeredTrainData = trainDataList.find(e => String(e.train_id) === String(列番データ));
|
||||
|
||||
// 列車マスタに未登録でも在線カード自体は残す。
|
||||
// 推測した種別・行先等は表示せず、列番と車両アイコンだけの最小表示にする。
|
||||
if(trainDataListReady && !registeredTrainData){
|
||||
行き先情報.innerText = "";
|
||||
${uiSetting === "tokyo" ? `
|
||||
行き先情報.insertAdjacentHTML('beforebegin', "<div data-jrs-train-data='unavailable' style='width:100%;display:flex;flex:1;flex-direction:" + (isLeft ? "column-reverse" : "column") + ";font-weight:bold;-webkit-text-size-adjust:100%;text-size-adjust:100%;'><p style='font-size:6px;padding:0;color:black;text-align:center;'>" + 列番データ + "</p><div style='flex:1;'></div><p style='font-size:8px;font-weight:bold;padding:0;text-align:center;color:" + (hasProblem ? "red" : "black") + ";" + (hasProblem ? "animation:_jrs_blink 3s linear infinite;" : "") + "'>" + (hasProblem ? "‼️停止中‼️" : "") + "</p></div>");
|
||||
` : `
|
||||
var _missingTextColor = _isDark ? 'white' : 'black';
|
||||
行き先情報.insertAdjacentHTML('beforebegin', "<p data-jrs-train-data='unavailable' style='font-size:10px;padding:0;color:" + _missingTextColor + ";'>" + 列番データ + "</p>");
|
||||
`}
|
||||
行き先情報.remove();
|
||||
return;
|
||||
}
|
||||
try{
|
||||
const diagram = trainDiagramData2[列番データ] || trainTimeInfo[列番データ];
|
||||
if(diagram){
|
||||
@@ -944,6 +969,10 @@ export const injectJavascriptData = ({
|
||||
}
|
||||
|
||||
const optionalTextColor = optionalText.includes("最終") ? "red" : "black";
|
||||
const hasViaData = !!viaData && viaData.trim() !== "";
|
||||
const hasOptionalText = !!optionalText && optionalText.trim() !== "";
|
||||
const hasTrainName = !!trainName && trainName.trim() !== "";
|
||||
const hasToData = !!ToData && ToData.trim() !== "";
|
||||
const trainTypeTextStyle = "font-size:10px;font-weight:bold;font-style:italic;padding:0;margin:0;color:#ffffff;text-align:center;line-height:1;";
|
||||
const trainTypeFrameStyle = "position:relative;height:10px;width:100%;overflow:hidden;display:flex;align-items:center;justify-content:center;";
|
||||
const trainTypeMainStyle = trainTypeTextStyle + "display:flex;align-items:center;justify-content:center;width:100%;height:100%;";
|
||||
@@ -997,12 +1026,17 @@ export const injectJavascriptData = ({
|
||||
badgeHtml += "<div style='position:absolute;" + badgePosition + ":0;" + offsetStyle + "background-color:#44bb44;border-radius:50%;border:1px solid #228822;width:19px;height:19px;box-sizing:border-box;overflow:hidden;display:flex;align-items:center;justify-content:center;'><img src='https://storage.haruk.in/elesite_logo.jpg' style='width:19px;height:19px;'/></div>";
|
||||
}
|
||||
|
||||
行き先情報.insertAdjacentHTML('beforebegin', "<div style='width:100%;display:flex;flex:1;flex-direction:"+(isLeft ? "column-reverse" : "column") + ";font-weight:bold;-webkit-text-size-adjust:100%;text-size-adjust:100%;'>" + badgeHtml + "<p style='font-size:6px;padding:0;color:black;text-align:center;'>" + (TrainNumberOverride ? TrainNumberOverride : TrainNumber) + "</p><div style='flex:1;'></div><p style='font-size:6px;font-weight:bold;padding:0;color:black;text-align:center;border-style:solid;border-width: "+(!!yosan2Color ? "2px" : "0px")+";border-color:" + yosan2Color + "'>" + viaData + "</p><p style='font-size:8px;font-weight:bold;padding:0;color: " + optionalTextColor + ";text-align:center;'>" + optionalText + "</p><p style='font-size:8px;font-weight:bold;padding:0;color:black;text-align:center;'>" + trainName + "</p><div style='width:100%;height:13px;box-sizing:border-box;display:flex;align-items:center;justify-content:center;background:" + gradient + ";overflow:hidden;'><p style='font-size:10px;font-weight:bold;padding:0;margin:0;color:white;line-height:1;text-align:center;text-shadow:1px 1px 0px #00000030, -1px -1px 0px #00000030,-1px 1px 0px #00000030, 1px -1px 0px #00000030,1px 0px 0px #00000030, -1px 0px 0px #00000030,0px 1px 0px #00000030, 0px -1px 0px #00000030;'>" + (ToData ? ToData + "行" : ToData) + "</p></div><div style='width:100%;height:13px;box-sizing:border-box;background:" + trainTypeColor + ";border-radius:"+(isLeft ? "4px 4px 0 0" : "0 0 4px 4px")+";display:flex;align-items:center;justify-content:center;padding:" + trainTypeContainerPadding + ";margin:0;overflow:hidden;'>" + trainTypeHtml + "</div><p style='font-size:8px;font-weight:bold;padding:0;text-align:center;color: "+(hasProblem ? "red": "black")+"; "+(hasProblem ? "animation:_jrs_blink 3s linear infinite;" : "")+"'>" + (hasProblem ? "‼️停止中‼️" : "") + "</p></div>");
|
||||
const viaDataHtml = hasViaData ? "<p style='font-size:6px;font-weight:bold;padding:0;margin:0;color:black;text-align:center;border-style:solid;border-width: " + (!!yosan2Color ? "2px" : "0px") + ";border-color:" + yosan2Color + "'>" + viaData + "</p>" : "";
|
||||
const optionalTextHtml = hasOptionalText ? "<p style='font-size:8px;font-weight:bold;padding:0;margin:0;color: " + optionalTextColor + ";text-align:center;'>" + optionalText + "</p>" : "";
|
||||
const trainNameHtml = hasTrainName ? "<p style='font-size:8px;font-weight:bold;padding:0;margin:0;color:black;text-align:center;'>" + trainName + "</p>" : "";
|
||||
const toDataHtml = hasToData ? "<div style='width:100%;min-height:13px;height:auto;box-sizing:border-box;display:flex;align-items:center;justify-content:center;background:" + gradient + ";padding:1px 0;overflow:visible;'><p style='font-size:10px;font-weight:bold;padding:0;margin:0;color:white;line-height:1.1;text-align:center;text-shadow:1px 1px 0px #00000030, -1px -1px 0px #00000030,-1px 1px 0px #00000030, 1px -1px 0px #00000030,1px 0px 0px #00000030, -1px 0px 0px #00000030,0px 1px 0px #00000030, 0px -1px 0px #00000030;'>" + ToData + "行</p></div>" : "";
|
||||
const problemHtml = hasProblem ? "<p style='font-size:8px;font-weight:bold;padding:0;margin:0;text-align:center;color:red;animation:_jrs_blink 3s linear infinite;'>‼️停止中‼️</p>" : "";
|
||||
行き先情報.insertAdjacentHTML('beforebegin', "<div style='width:100%;display:flex;flex:1;flex-direction:"+(isLeft ? "column-reverse" : "column") + ";font-weight:bold;-webkit-text-size-adjust:100%;text-size-adjust:100%;'>" + badgeHtml + "<p style='font-size:6px;padding:0;margin:0;color:black;text-align:center;'>" + (TrainNumberOverride ? TrainNumberOverride : TrainNumber) + "</p><div style='flex:1;'></div>" + viaDataHtml + optionalTextHtml + trainNameHtml + toDataHtml + "<div style='width:100%;height:13px;box-sizing:border-box;background:" + trainTypeColor + ";border-radius:"+(isLeft ? "4px 4px 0 0" : "0 0 4px 4px")+";display:flex;align-items:center;justify-content:center;padding:" + trainTypeContainerPadding + ";margin:0;overflow:hidden;'>" + trainTypeHtml + "</div>" + problemHtml + "</div>");
|
||||
`: `
|
||||
var _defTextColor = _isDark ? 'white' : 'black';
|
||||
行き先情報.insertAdjacentHTML('beforebegin', "<p style='font-size:10px;font-weight:bold;padding:0;color:" + _defTextColor + ";'>" + returnText1 + "</p>");
|
||||
行き先情報.insertAdjacentHTML('beforebegin', "<div style='display:inline-flex;flex-direction:row;'><p style='font-size:10px;font-weight: bold;padding:0;color:" + _defTextColor + ";'>" + (ToData ? ToData + "行 " : ToData) + "</p><p style='font-size:10px;padding:0;color:" + _defTextColor + ";'>" + (TrainNumberOverride ? TrainNumberOverride : TrainNumber) + "</p></div>");
|
||||
行き先情報.insertAdjacentHTML('beforebegin', "<p style='font-size:10px;font-weight:bold;padding:0;color: "+(hasProblem ? "red": _defTextColor)+"; "+(hasProblem ? "animation:_jrs_blink 3s linear infinite;" : "")+"'>" + (hasProblem ? "‼️停止中‼️" : "") + "</p>");
|
||||
if(returnText1){ 行き先情報.insertAdjacentHTML('beforebegin', "<p style='font-size:10px;font-weight:bold;padding:0;margin:0;color:" + _defTextColor + ";'>" + returnText1 + "</p>"); }
|
||||
行き先情報.insertAdjacentHTML('beforebegin', "<div style='display:inline-flex;flex-direction:row;'><p style='font-size:10px;font-weight: bold;padding:0;margin:0;color:" + _defTextColor + ";'>" + (ToData ? ToData + "行 " : "") + "</p><p style='font-size:10px;padding:0;margin:0;color:" + _defTextColor + ";'>" + (TrainNumberOverride ? TrainNumberOverride : TrainNumber) + "</p></div>");
|
||||
if(hasProblem){ 行き先情報.insertAdjacentHTML('beforebegin', "<p style='font-size:10px;font-weight:bold;padding:0;margin:0;color:red;animation:_jrs_blink 3s linear infinite;'>‼️停止中‼️</p>"); }
|
||||
`}
|
||||
}
|
||||
`;
|
||||
@@ -1022,13 +1056,7 @@ const setNewTrainItem = (element,hasProblem,type)=>{
|
||||
element.style.backgroundColor = _bgc;
|
||||
}else{
|
||||
element.style.backgroundColor = '#ffffffff';
|
||||
if(element.getAttribute('offclick').includes("express")){
|
||||
element.style.borderColor = '#ff0000ff';
|
||||
}else if(element.getAttribute('offclick').includes("rapid")){
|
||||
element.style.borderColor = '#008cffff';
|
||||
}else{
|
||||
element.style.borderColor = _isDark ? '#30363d' : 'black';
|
||||
}
|
||||
element.style.borderColor = _isDark ? '#30363d' : 'black';
|
||||
}
|
||||
element.style.borderWidth = _isDark ? '1px' : '2px';
|
||||
element.style.borderStyle = 'solid';
|
||||
|
||||
@@ -517,7 +517,7 @@ export const Menu: FC<props> = (props) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{originalStationList.length != 0 && isHeavyContentReady && (
|
||||
{Object.keys(originalStationList).length !== 0 && isHeavyContentReady && (
|
||||
<>
|
||||
<CarouselBox
|
||||
{...{
|
||||
|
||||
+5
-7
@@ -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)-"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
+847
-32
@@ -247,10 +247,17 @@ const buildOperationPageScript = (layout: {
|
||||
'.jrs-capture-page-link:active{opacity:.9 !important;transform:translateY(1px) !important;}',
|
||||
'.jrs-capture-page-link.is-secondary{background:#ffffff !important;color:#0076a8 !important;border:1px solid #0099CB !important;box-shadow:none !important;}',
|
||||
'.jrs-capture-page-link.is-secondary:visited{color:#0076a8 !important;}',
|
||||
'.jrs-capture-wrap{display:block !important;text-align:right !important;margin:12px 0 8px !important;}',
|
||||
'.jrs-capture-page-link.is-x{background:linear-gradient(135deg,#111827 0%,#0f76a8 100%) !important;color:#fff !important;border:none !important;box-shadow:0 6px 14px rgba(15,118,168,.24) !important;}',
|
||||
'.jrs-capture-page-link.is-x:visited{color:#fff !important;}',
|
||||
'.jrs-capture-page-link.is-disabled,.jrs-capture-link.is-disabled,.jrs-subcapture-link.is-disabled{pointer-events:none !important;opacity:.52 !important;transform:none !important;}',
|
||||
'.jrs-capture-wrap{display:flex !important;flex-wrap:wrap !important;justify-content:flex-end !important;gap:8px !important;margin:12px 0 8px !important;}',
|
||||
'.jrs-capture-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;}',
|
||||
@@ -394,8 +401,38 @@ const buildOperationPageScript = (layout: {
|
||||
}
|
||||
|
||||
function postMessage(payload) {
|
||||
if (!window.ReactNativeWebView) return;
|
||||
payload.type = 'operationInfoCapture';
|
||||
postNativeMessage(payload);
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function setCaptureButtonsBusy(isBusy) {
|
||||
qa('.jrs-capture-page-link, .jrs-capture-link, .jrs-subcapture-link').forEach(function(element) {
|
||||
if (!element || !element.classList) return;
|
||||
element.classList[isBusy ? 'add' : 'remove']('is-disabled');
|
||||
element.setAttribute('aria-disabled', isBusy ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function runCaptureAction(action) {
|
||||
if (window.__jrCaptureBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.__jrCaptureBusy = true;
|
||||
setCaptureButtonsBusy(true);
|
||||
|
||||
Promise.resolve()
|
||||
.then(action)
|
||||
.catch(function() {
|
||||
postMessage({ error: true });
|
||||
})
|
||||
.then(function() {
|
||||
window.setTimeout(function() {
|
||||
window.__jrCaptureBusy = false;
|
||||
setCaptureButtonsBusy(false);
|
||||
}, 120);
|
||||
});
|
||||
}
|
||||
|
||||
function parseDetailBlocks(html) {
|
||||
@@ -660,6 +697,734 @@ const buildOperationPageScript = (layout: {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
var X_CAPTURE_PAGE_WIDTH = 1080;
|
||||
var X_CAPTURE_PAGE_HEIGHT = 1440;
|
||||
var X_CAPTURE_SAFE_X = 80;
|
||||
var X_CAPTURE_HEADER_TOP = 76;
|
||||
var X_CAPTURE_CONTENT_TOP = 208;
|
||||
var X_CAPTURE_CONTENT_BOTTOM = 1218;
|
||||
var X_CAPTURE_FOOTER_Y = 1262;
|
||||
var X_CAPTURE_ITEM_GAP = 26;
|
||||
var X_CAPTURE_UNIT_GAP = 18;
|
||||
|
||||
function getXCaptureNotice() {
|
||||
return 'この画像は非公式アプリで生成したものです。最新情報・詳細はJR四国公式の運行情報をご確認ください。';
|
||||
}
|
||||
|
||||
function getXPageNumberText(pageIndex, totalPages) {
|
||||
return String(pageIndex + 1) + '/' + String(totalPages);
|
||||
}
|
||||
|
||||
function parseUpdatedAtValue(value) {
|
||||
var text = strip(value);
|
||||
if (!text) return null;
|
||||
var normalized = text
|
||||
.replace(/更新日時[::]?/g, '')
|
||||
.replace(/年/g, '/')
|
||||
.replace(/月/g, '/')
|
||||
.replace(/日/g, '')
|
||||
.replace(/時/g, ':')
|
||||
.replace(/分/g, '')
|
||||
.replace(/\./g, '/')
|
||||
.replace(/-/g, '/')
|
||||
.trim();
|
||||
var parsed = Date.parse(normalized);
|
||||
return isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
function getLatestUpdatedAt(items) {
|
||||
var bestText = '';
|
||||
var bestValue = null;
|
||||
(items || []).forEach(function(item) {
|
||||
var updatedAt = strip(item && item.updatedAt);
|
||||
if (!updatedAt) return;
|
||||
var parsed = parseUpdatedAtValue(updatedAt);
|
||||
if (parsed == null) {
|
||||
if (!bestText) bestText = updatedAt;
|
||||
return;
|
||||
}
|
||||
if (bestValue == null || parsed > bestValue) {
|
||||
bestValue = parsed;
|
||||
bestText = updatedAt;
|
||||
}
|
||||
});
|
||||
return bestText;
|
||||
}
|
||||
|
||||
function measureXHeroHeader(ctx, item, contentWidth) {
|
||||
var textWidth = contentWidth - 64;
|
||||
ctx.font = "800 64px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
var titleLines = wrapText(ctx, strip(item.title) || '運行情報', textWidth).slice(0, 4);
|
||||
ctx.font = "700 30px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
var subTitleLines = strip(item.subTitle) ? wrapText(ctx, item.subTitle, textWidth) : [];
|
||||
ctx.font = "500 24px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
var updatedLines = strip(item.updatedAt) ? wrapText(ctx, item.updatedAt, textWidth) : [];
|
||||
var leadLines = [];
|
||||
var height = 44 + titleLines.length * 70 + (subTitleLines.length ? 14 + subTitleLines.length * 36 : 0) + (updatedLines.length ? 14 + updatedLines.length * 30 : 0) + (leadLines.length ? 18 + leadLines.length * 34 : 0) + 28;
|
||||
return {
|
||||
titleLines: titleLines,
|
||||
subTitleLines: subTitleLines,
|
||||
updatedLines: updatedLines,
|
||||
leadLines: leadLines,
|
||||
height: Math.max(260, Math.min(height, 420))
|
||||
};
|
||||
}
|
||||
|
||||
function measureXDetailHeader(ctx, item, contentWidth, continued) {
|
||||
var textWidth = contentWidth - 54;
|
||||
ctx.font = "800 38px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
var titleLines = wrapText(ctx, strip(item.title) || '運行情報', textWidth);
|
||||
ctx.font = "700 26px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
var subTitleLines = strip(item.subTitle) ? wrapText(ctx, item.subTitle, textWidth) : [];
|
||||
ctx.font = "500 22px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
var updatedLines = strip(item.updatedAt) ? wrapText(ctx, item.updatedAt, textWidth) : [];
|
||||
var height = 30 + titleLines.length * 44 + (subTitleLines.length ? 10 + subTitleLines.length * 32 : 0) + (updatedLines.length ? 12 + updatedLines.length * 28 : 0) + 24;
|
||||
return {
|
||||
titleLines: titleLines,
|
||||
subTitleLines: subTitleLines,
|
||||
updatedLines: updatedLines,
|
||||
continued: !!continued,
|
||||
height: Math.max(118, height)
|
||||
};
|
||||
}
|
||||
|
||||
function createXDetailUnits(ctx, item, bodyWidth) {
|
||||
var units = [];
|
||||
var sections = [];
|
||||
var currentSection = { heading: '', body: [] };
|
||||
var blocks = item && item.blocks ? item.blocks : [];
|
||||
|
||||
blocks.forEach(function(block) {
|
||||
if (block.type === 'badge') {
|
||||
if (currentSection.heading || currentSection.body.length) {
|
||||
sections.push(currentSection);
|
||||
}
|
||||
currentSection = { heading: strip(block.text), body: [] };
|
||||
return;
|
||||
}
|
||||
|
||||
var bodyText = strip(block.text);
|
||||
if (bodyText) currentSection.body.push(bodyText);
|
||||
});
|
||||
|
||||
if (currentSection.heading || currentSection.body.length) {
|
||||
sections.push(currentSection);
|
||||
}
|
||||
|
||||
sections.forEach(function(section) {
|
||||
ctx.font = "700 28px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
var headingLines = section.heading ? wrapText(ctx, section.heading, bodyWidth - 24) : [];
|
||||
ctx.font = "400 28px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
var bodyLines = [];
|
||||
section.body.forEach(function(bodyText) {
|
||||
bodyLines = bodyLines.concat(wrapText(ctx, bodyText, bodyWidth));
|
||||
});
|
||||
if (!bodyLines.length && !headingLines.length) return;
|
||||
units.push({
|
||||
headingLines: headingLines,
|
||||
bodyLines: bodyLines,
|
||||
lineHeight: 39
|
||||
});
|
||||
});
|
||||
|
||||
if (!units.length) {
|
||||
ctx.font = "400 28px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
units.push({
|
||||
headingLines: [],
|
||||
bodyLines: wrapText(ctx, '詳細情報はJR四国公式の運行情報をご確認ください。', bodyWidth),
|
||||
lineHeight: 39
|
||||
});
|
||||
}
|
||||
|
||||
return units;
|
||||
}
|
||||
|
||||
function cloneXDetailUnits(units) {
|
||||
return (units || []).map(function(unit) {
|
||||
return {
|
||||
headingLines: (unit.headingLines || []).slice(),
|
||||
bodyLines: (unit.bodyLines || []).slice(),
|
||||
lineHeight: unit.lineHeight
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createXOverflowNoticeUnit(ctx, bodyWidth) {
|
||||
ctx.font = "700 28px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
var headingLines = wrapText(ctx, '続きの情報', bodyWidth - 24);
|
||||
ctx.font = "400 28px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
return {
|
||||
headingLines: headingLines,
|
||||
bodyLines: wrapText(ctx, '4枚に収まらない情報があります。続きと最新情報はJR四国公式の運行情報をご確認ください。', bodyWidth),
|
||||
lineHeight: 39
|
||||
};
|
||||
}
|
||||
|
||||
function getXUnitHeight(unit) {
|
||||
var headingHeight = unit.headingLines && unit.headingLines.length ? Math.max(56, unit.headingLines.length * 35 + 20) : 0;
|
||||
var bodyHeight = Math.max(unit.bodyLines.length, 1) * unit.lineHeight;
|
||||
return 22 + headingHeight + (headingHeight ? 14 : 0) + bodyHeight + 18;
|
||||
}
|
||||
|
||||
function buildXUnitChunk(unit, availableHeight) {
|
||||
var fullHeight = getXUnitHeight(unit);
|
||||
if (fullHeight <= availableHeight) {
|
||||
return {
|
||||
chunk: {
|
||||
headingLines: unit.headingLines.slice(),
|
||||
bodyLines: unit.bodyLines.slice(),
|
||||
lineHeight: unit.lineHeight,
|
||||
height: fullHeight
|
||||
},
|
||||
consumed: true,
|
||||
rest: null,
|
||||
height: fullHeight
|
||||
};
|
||||
}
|
||||
|
||||
var headingHeight = unit.headingLines && unit.headingLines.length ? Math.max(56, unit.headingLines.length * 35 + 20) : 0;
|
||||
var availableForBody = availableHeight - 22 - headingHeight - (headingHeight ? 14 : 0) - 18;
|
||||
var lineCapacity = Math.floor(availableForBody / unit.lineHeight);
|
||||
var minimumLines = headingHeight ? 2 : 3;
|
||||
if (lineCapacity < minimumLines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var firstLines = unit.bodyLines.slice(0, lineCapacity);
|
||||
if (!firstLines.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var restLines = unit.bodyLines.slice(lineCapacity);
|
||||
var chunk = {
|
||||
headingLines: unit.headingLines.slice(),
|
||||
bodyLines: firstLines,
|
||||
lineHeight: unit.lineHeight
|
||||
};
|
||||
chunk.height = getXUnitHeight(chunk);
|
||||
|
||||
return {
|
||||
chunk: chunk,
|
||||
consumed: restLines.length === 0,
|
||||
rest: restLines.length ? {
|
||||
headingLines: [],
|
||||
bodyLines: restLines,
|
||||
lineHeight: unit.lineHeight
|
||||
} : null,
|
||||
height: chunk.height
|
||||
};
|
||||
}
|
||||
|
||||
function fillXPageUnits(units, unitIndex, availableHeight) {
|
||||
var pageUnits = [];
|
||||
var remainingHeight = availableHeight;
|
||||
|
||||
while (unitIndex < units.length) {
|
||||
var budget = remainingHeight;
|
||||
if (pageUnits.length) {
|
||||
budget -= X_CAPTURE_UNIT_GAP;
|
||||
}
|
||||
if (budget <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
var chunkInfo = buildXUnitChunk(units[unitIndex], budget);
|
||||
if (!chunkInfo) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (pageUnits.length) {
|
||||
remainingHeight -= X_CAPTURE_UNIT_GAP;
|
||||
}
|
||||
pageUnits.push(chunkInfo.chunk);
|
||||
remainingHeight -= chunkInfo.height;
|
||||
|
||||
if (chunkInfo.consumed) {
|
||||
unitIndex += 1;
|
||||
} else if (chunkInfo.rest) {
|
||||
units[unitIndex] = chunkInfo.rest;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
units: pageUnits,
|
||||
unitIndex: unitIndex,
|
||||
remainingHeight: remainingHeight
|
||||
};
|
||||
}
|
||||
|
||||
function paginateXDetailUnits(units, unitIndex, pageCount, availableHeight) {
|
||||
var workingUnits = cloneXDetailUnits(units);
|
||||
var nextUnitIndex = unitIndex;
|
||||
var pageUnits = [];
|
||||
|
||||
for (var pageIndex = 0; pageIndex < pageCount && nextUnitIndex < workingUnits.length; pageIndex += 1) {
|
||||
var fill = fillXPageUnits(workingUnits, nextUnitIndex, availableHeight);
|
||||
if (!fill.units.length) break;
|
||||
pageUnits.push(fill.units);
|
||||
nextUnitIndex = fill.unitIndex;
|
||||
}
|
||||
|
||||
return {
|
||||
pages: pageUnits,
|
||||
consumed: nextUnitIndex >= workingUnits.length
|
||||
};
|
||||
}
|
||||
|
||||
function findBalancedXDetailHeight(units, unitIndex, pageCount, maxHeight) {
|
||||
var low = 180;
|
||||
var high = maxHeight;
|
||||
while (low < high) {
|
||||
var middle = Math.floor((low + high) / 2);
|
||||
var attempt = paginateXDetailUnits(units, unitIndex, pageCount, middle);
|
||||
if (attempt.consumed) {
|
||||
high = middle;
|
||||
} else {
|
||||
low = middle + 1;
|
||||
}
|
||||
}
|
||||
return Math.min(maxHeight, high + 18);
|
||||
}
|
||||
|
||||
function buildXPagesForItem(ctx, item, itemIndex, contentWidth) {
|
||||
var bodyWidth = contentWidth - 32;
|
||||
var maxHeight = X_CAPTURE_CONTENT_BOTTOM - X_CAPTURE_CONTENT_TOP;
|
||||
var heroHeader = measureXHeroHeader(ctx, item, contentWidth);
|
||||
var units = createXDetailUnits(ctx, item, bodyWidth);
|
||||
var unitIndex = 0;
|
||||
var pages = [];
|
||||
|
||||
pages.push({
|
||||
kind: 'hero',
|
||||
item: item,
|
||||
itemIndex: itemIndex,
|
||||
heroHeader: heroHeader,
|
||||
units: [],
|
||||
hasMore: units.length > 0
|
||||
});
|
||||
|
||||
var detailHeader = measureXDetailHeader(ctx, item, contentWidth, true);
|
||||
var detailAvailableHeight = maxHeight - detailHeader.height;
|
||||
var singleDetail = paginateXDetailUnits(units, unitIndex, 1, detailAvailableHeight);
|
||||
if (singleDetail.consumed && singleDetail.pages.length === 1) {
|
||||
pages.push({
|
||||
kind: 'detail',
|
||||
item: item,
|
||||
itemIndex: itemIndex,
|
||||
header: detailHeader,
|
||||
units: singleDetail.pages[0],
|
||||
hasMore: false
|
||||
});
|
||||
return pages;
|
||||
}
|
||||
|
||||
var detailColumnGap = 24;
|
||||
var detailColumnWidth = Math.floor((contentWidth - detailColumnGap) / 2);
|
||||
var columnUnits = createXDetailUnits(ctx, item, detailColumnWidth - 36);
|
||||
var twoColumnDetail = paginateXDetailUnits(columnUnits, 0, 2, detailAvailableHeight);
|
||||
if (twoColumnDetail.consumed && twoColumnDetail.pages.length === 2) {
|
||||
pages.push({
|
||||
kind: 'detail-columns',
|
||||
item: item,
|
||||
itemIndex: itemIndex,
|
||||
header: detailHeader,
|
||||
columns: twoColumnDetail.pages,
|
||||
columnGap: detailColumnGap,
|
||||
hasMore: false
|
||||
});
|
||||
return pages;
|
||||
}
|
||||
|
||||
var greedyDetails = paginateXDetailUnits(units, unitIndex, 3, detailAvailableHeight);
|
||||
if (greedyDetails.consumed && greedyDetails.pages.length) {
|
||||
var balancedHeight = findBalancedXDetailHeight(units, unitIndex, greedyDetails.pages.length, detailAvailableHeight);
|
||||
var balancedDetails = paginateXDetailUnits(units, unitIndex, greedyDetails.pages.length, balancedHeight);
|
||||
var selectedDetails = balancedDetails.consumed ? balancedDetails.pages : greedyDetails.pages;
|
||||
selectedDetails.forEach(function(detailUnits, detailIndex) {
|
||||
pages.push({
|
||||
kind: 'detail',
|
||||
item: item,
|
||||
itemIndex: itemIndex,
|
||||
header: measureXDetailHeader(ctx, item, contentWidth, true),
|
||||
units: detailUnits,
|
||||
hasMore: detailIndex < selectedDetails.length - 1
|
||||
});
|
||||
});
|
||||
return pages;
|
||||
}
|
||||
|
||||
var continued = unitIndex < units.length;
|
||||
while (unitIndex < units.length) {
|
||||
var header = measureXDetailHeader(ctx, item, contentWidth, continued);
|
||||
var availableHeight = maxHeight - header.height;
|
||||
var isLastAllowedPage = pages.length === 3;
|
||||
var detailFill;
|
||||
|
||||
if (isLastAllowedPage) {
|
||||
var unitsBeforeFinalFill = cloneXDetailUnits(units);
|
||||
var fullFinalFill = fillXPageUnits(units, unitIndex, availableHeight);
|
||||
if (fullFinalFill.unitIndex >= units.length) {
|
||||
detailFill = fullFinalFill;
|
||||
} else {
|
||||
units = unitsBeforeFinalFill;
|
||||
var overflowNotice = createXOverflowNoticeUnit(ctx, bodyWidth);
|
||||
var noticeHeight = getXUnitHeight(overflowNotice);
|
||||
detailFill = fillXPageUnits(units, unitIndex, Math.max(0, availableHeight - noticeHeight - X_CAPTURE_UNIT_GAP));
|
||||
overflowNotice.height = noticeHeight;
|
||||
detailFill.units.push(overflowNotice);
|
||||
detailFill.unitIndex = units.length;
|
||||
}
|
||||
} else {
|
||||
detailFill = fillXPageUnits(units, unitIndex, availableHeight);
|
||||
}
|
||||
if (!detailFill.units.length) {
|
||||
return null;
|
||||
}
|
||||
unitIndex = detailFill.unitIndex;
|
||||
pages.push({
|
||||
kind: 'detail',
|
||||
item: item,
|
||||
itemIndex: itemIndex,
|
||||
header: header,
|
||||
units: detailFill.units,
|
||||
hasMore: unitIndex < units.length
|
||||
});
|
||||
continued = unitIndex < units.length;
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
function getXItemTopicLabel(page) {
|
||||
return '運行情報';
|
||||
}
|
||||
|
||||
function drawXPageChrome(ctx, pageIndex, totalPages, subHeading) {
|
||||
var titleX = X_CAPTURE_SAFE_X;
|
||||
ctx.fillStyle = '#0099CB';
|
||||
ctx.fillRect(titleX, X_CAPTURE_HEADER_TOP + 4, 12, 76);
|
||||
ctx.fillStyle = '#0f1720';
|
||||
ctx.font = "800 56px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
ctx.fillText('JR四国 列車運行情報', titleX + 28, X_CAPTURE_HEADER_TOP + 57);
|
||||
ctx.fillStyle = '#4b5563';
|
||||
ctx.font = "700 24px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
ctx.fillText(subHeading, titleX + 30, X_CAPTURE_HEADER_TOP + 96);
|
||||
ctx.fillStyle = '#0f1720';
|
||||
ctx.font = "700 24px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
var pageText = getXPageNumberText(pageIndex, totalPages);
|
||||
var pageTextWidth = ctx.measureText(pageText).width;
|
||||
ctx.fillText(pageText, X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X - pageTextWidth, X_CAPTURE_HEADER_TOP + 52);
|
||||
}
|
||||
|
||||
function drawXFooter(ctx, pageIndex, totalPages) {
|
||||
var notice = getXCaptureNotice();
|
||||
ctx.strokeStyle = '#d6dde5';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(X_CAPTURE_SAFE_X, X_CAPTURE_FOOTER_Y - 18);
|
||||
ctx.lineTo(X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X, X_CAPTURE_FOOTER_Y - 18);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#5b6470';
|
||||
ctx.font = "400 20px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
wrapText(ctx, notice, X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X * 2 - 90).slice(0, 3).forEach(function(line, index) {
|
||||
ctx.fillText(line, X_CAPTURE_SAFE_X, X_CAPTURE_FOOTER_Y + index * 26);
|
||||
});
|
||||
ctx.fillStyle = '#111827';
|
||||
ctx.font = "800 22px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
var pageText = getXPageNumberText(pageIndex, totalPages);
|
||||
var pageWidth = ctx.measureText(pageText).width;
|
||||
ctx.fillText(pageText, X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X - pageWidth, X_CAPTURE_FOOTER_Y + 52);
|
||||
}
|
||||
|
||||
function drawXDetailHeaderBlock(ctx, header, x, y, width) {
|
||||
ctx.fillStyle = '#0099CB';
|
||||
ctx.fillRect(x, y, width, header.height);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = "800 38px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
var textY = y + 44;
|
||||
header.titleLines.forEach(function(line) {
|
||||
ctx.fillText(line, x + 22, textY);
|
||||
textY += 44;
|
||||
});
|
||||
if (header.subTitleLines.length) {
|
||||
ctx.font = "700 26px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
textY += 2;
|
||||
header.subTitleLines.forEach(function(line) {
|
||||
ctx.fillText(line, x + 22, textY);
|
||||
textY += 32;
|
||||
});
|
||||
}
|
||||
if (header.updatedLines.length) {
|
||||
ctx.font = "500 22px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
textY += 4;
|
||||
header.updatedLines.forEach(function(line) {
|
||||
ctx.fillText(line, x + 22, textY);
|
||||
textY += 28;
|
||||
});
|
||||
}
|
||||
if (header.continued) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,.22)';
|
||||
ctx.fillRect(x + width - 152, y + 18, 124, 34);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = "700 20px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
ctx.fillText('続き', x + width - 109, y + 41);
|
||||
}
|
||||
}
|
||||
|
||||
function drawXUnitBlock(ctx, unit, x, y, width) {
|
||||
ctx.strokeStyle = '#d7dee7';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(x, y, width, unit.height);
|
||||
var textY = y + 34;
|
||||
if (unit.headingLines.length) {
|
||||
var headingHeight = Math.max(56, unit.headingLines.length * 35 + 20);
|
||||
ctx.fillStyle = '#f3f8fb';
|
||||
ctx.fillRect(x, y, width, headingHeight + 20);
|
||||
ctx.fillStyle = '#0099CB';
|
||||
ctx.fillRect(x + 14, y + 12, 8, headingHeight - 4);
|
||||
ctx.fillStyle = '#0f1720';
|
||||
ctx.font = "700 28px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
unit.headingLines.forEach(function(line) {
|
||||
ctx.fillText(line, x + 34, textY);
|
||||
textY += 35;
|
||||
});
|
||||
textY = y + headingHeight + 34;
|
||||
}
|
||||
ctx.fillStyle = '#111827';
|
||||
ctx.font = "400 28px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
unit.bodyLines.forEach(function(line) {
|
||||
ctx.fillText(line, x + 18, textY);
|
||||
textY += unit.lineHeight;
|
||||
});
|
||||
}
|
||||
|
||||
async function buildXHeroPage(page, pageIndex, totalPages) {
|
||||
var canvas = document.createElement('canvas');
|
||||
var ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
|
||||
canvas.width = X_CAPTURE_PAGE_WIDTH;
|
||||
canvas.height = X_CAPTURE_PAGE_HEIGHT;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
drawXPageChrome(ctx, pageIndex, totalPages, '運行情報・路線図');
|
||||
|
||||
var contentWidth = X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X * 2;
|
||||
var heroTop = 180;
|
||||
var hero = page.heroHeader;
|
||||
var heroWidth = contentWidth;
|
||||
ctx.fillStyle = '#0e7fb1';
|
||||
ctx.fillRect(X_CAPTURE_SAFE_X, heroTop, heroWidth, hero.height);
|
||||
ctx.fillStyle = '#cfeefe';
|
||||
ctx.font = "800 22px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
ctx.fillText(getXItemTopicLabel(page), X_CAPTURE_SAFE_X + 28, heroTop + 34);
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = "800 64px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
var textY = heroTop + 92;
|
||||
hero.titleLines.forEach(function(line) {
|
||||
ctx.fillText(line, X_CAPTURE_SAFE_X + 26, textY);
|
||||
textY += 70;
|
||||
});
|
||||
if (hero.subTitleLines.length) {
|
||||
ctx.fillStyle = '#def5ff';
|
||||
ctx.font = "700 30px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
hero.subTitleLines.forEach(function(line) {
|
||||
ctx.fillText(line, X_CAPTURE_SAFE_X + 28, textY);
|
||||
textY += 36;
|
||||
});
|
||||
}
|
||||
if (hero.updatedLines.length) {
|
||||
ctx.fillStyle = '#d4ecfa';
|
||||
ctx.font = "500 24px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
hero.updatedLines.forEach(function(line) {
|
||||
ctx.fillText(line, X_CAPTURE_SAFE_X + 28, textY + 10);
|
||||
textY += 30;
|
||||
});
|
||||
}
|
||||
if (hero.leadLines.length) {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = "500 26px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
var leadY = heroTop + hero.height - hero.leadLines.length * 34 - 20;
|
||||
hero.leadLines.forEach(function(line, index) {
|
||||
ctx.fillText(line, X_CAPTURE_SAFE_X + 28, leadY + index * 34);
|
||||
});
|
||||
}
|
||||
|
||||
var mapCanvas;
|
||||
try {
|
||||
mapCanvas = await buildMapImage(contentWidth - 32);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
if (!mapCanvas) return null;
|
||||
|
||||
var mapTop = heroTop + hero.height + 24;
|
||||
ctx.strokeStyle = '#c7dcea';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(X_CAPTURE_SAFE_X, mapTop, contentWidth, mapCanvas.height + 32);
|
||||
ctx.drawImage(mapCanvas, X_CAPTURE_SAFE_X + 16, mapTop + 16, mapCanvas.width, mapCanvas.height);
|
||||
|
||||
var detailY = mapTop + mapCanvas.height + 48;
|
||||
if (page.units.length) {
|
||||
page.units.forEach(function(unit, unitIndex) {
|
||||
drawXUnitBlock(ctx, unit, X_CAPTURE_SAFE_X, detailY, contentWidth);
|
||||
detailY += unit.height;
|
||||
if (unitIndex < page.units.length - 1) {
|
||||
detailY += X_CAPTURE_UNIT_GAP;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ctx.fillStyle = '#f3f8fb';
|
||||
ctx.fillRect(X_CAPTURE_SAFE_X, detailY, contentWidth, 112);
|
||||
ctx.fillStyle = '#0f1720';
|
||||
ctx.font = "700 28px -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Segoe UI', sans-serif";
|
||||
ctx.fillText(page.hasMore ? '詳細情報は2枚目以降へ →' : 'この題目の詳細はJR四国公式をご確認ください。', X_CAPTURE_SAFE_X + 24, detailY + 62);
|
||||
}
|
||||
|
||||
drawXFooter(ctx, pageIndex, totalPages);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function buildXDetailPage(page, pageIndex, totalPages) {
|
||||
var canvas = document.createElement('canvas');
|
||||
var ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
|
||||
canvas.width = X_CAPTURE_PAGE_WIDTH;
|
||||
canvas.height = X_CAPTURE_PAGE_HEIGHT;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
drawXPageChrome(ctx, pageIndex, totalPages, '詳細情報');
|
||||
|
||||
var y = X_CAPTURE_CONTENT_TOP;
|
||||
var width = X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X * 2;
|
||||
drawXDetailHeaderBlock(ctx, page.header, X_CAPTURE_SAFE_X, y, width);
|
||||
y += page.header.height;
|
||||
page.units.forEach(function(unit, unitIndex) {
|
||||
drawXUnitBlock(ctx, unit, X_CAPTURE_SAFE_X, y, width);
|
||||
y += unit.height;
|
||||
if (unitIndex < page.units.length - 1) {
|
||||
y += X_CAPTURE_UNIT_GAP;
|
||||
}
|
||||
});
|
||||
|
||||
drawXFooter(ctx, pageIndex, totalPages);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function buildXDetailColumnsPage(page, pageIndex, totalPages) {
|
||||
var canvas = document.createElement('canvas');
|
||||
var ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
|
||||
canvas.width = X_CAPTURE_PAGE_WIDTH;
|
||||
canvas.height = X_CAPTURE_PAGE_HEIGHT;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
drawXPageChrome(ctx, pageIndex, totalPages, '詳細情報');
|
||||
|
||||
var y = X_CAPTURE_CONTENT_TOP;
|
||||
var width = X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X * 2;
|
||||
drawXDetailHeaderBlock(ctx, page.header, X_CAPTURE_SAFE_X, y, width);
|
||||
y += page.header.height + X_CAPTURE_UNIT_GAP;
|
||||
|
||||
var gap = page.columnGap || 24;
|
||||
var columnWidth = Math.floor((width - gap) / 2);
|
||||
(page.columns || []).forEach(function(columnUnits, columnIndex) {
|
||||
var columnX = X_CAPTURE_SAFE_X + columnIndex * (columnWidth + gap);
|
||||
var columnY = y;
|
||||
columnUnits.forEach(function(unit, unitIndex) {
|
||||
drawXUnitBlock(ctx, unit, columnX, columnY, columnWidth);
|
||||
columnY += unit.height;
|
||||
if (unitIndex < columnUnits.length - 1) {
|
||||
columnY += X_CAPTURE_UNIT_GAP;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
drawXFooter(ctx, pageIndex, totalPages);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function getXFileToken(item, fallback) {
|
||||
var base = strip(item && (item.infoId || item.title)) || fallback || 'item';
|
||||
return base.replace(/[^0-9A-Za-z_-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 24) || 'item';
|
||||
}
|
||||
|
||||
function formatXPageFileIndex(index) {
|
||||
var value = String(index + 1);
|
||||
return value.length > 1 ? value : '0' + value;
|
||||
}
|
||||
|
||||
async function renderXPostImageSet(items, fileNameBase) {
|
||||
try {
|
||||
if (!items || !items.length) {
|
||||
postMessage({ error: true, reason: 'x-empty' });
|
||||
return;
|
||||
}
|
||||
|
||||
var measureCanvas = document.createElement('canvas');
|
||||
var measureCtx = measureCanvas.getContext('2d');
|
||||
if (!measureCtx) {
|
||||
postMessage({ error: true });
|
||||
return;
|
||||
}
|
||||
|
||||
var pages = [];
|
||||
var contentWidth = X_CAPTURE_PAGE_WIDTH - X_CAPTURE_SAFE_X * 2;
|
||||
for (var itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
|
||||
var itemPages = buildXPagesForItem(measureCtx, items[itemIndex], itemIndex, contentWidth);
|
||||
if (!itemPages) {
|
||||
postMessage({ error: true, reason: 'x-overflow' });
|
||||
return;
|
||||
}
|
||||
pages = pages.concat(itemPages);
|
||||
}
|
||||
|
||||
if (!pages.length || pages.length > 4) {
|
||||
postMessage({ error: true, reason: 'x-overflow' });
|
||||
return;
|
||||
}
|
||||
|
||||
var totalPages = pages.length;
|
||||
var timestamp = strip(fileNameBase) || String(Date.now());
|
||||
var renderedPages = [];
|
||||
for (var pageIndex = 0; pageIndex < pages.length; pageIndex += 1) {
|
||||
var page = pages[pageIndex];
|
||||
var canvas = page.kind === 'hero'
|
||||
? await buildXHeroPage(page, pageIndex, totalPages)
|
||||
: page.kind === 'detail-columns'
|
||||
? buildXDetailColumnsPage(page, pageIndex, totalPages)
|
||||
: buildXDetailPage(page, pageIndex, totalPages);
|
||||
if (!canvas) {
|
||||
postMessage({ error: true, reason: page.kind === 'hero' ? 'x-map' : undefined });
|
||||
return;
|
||||
}
|
||||
renderedPages.push({
|
||||
canvas: canvas,
|
||||
token: getXFileToken(page.item, String(page.itemIndex + 1)),
|
||||
kind: page.kind
|
||||
});
|
||||
}
|
||||
|
||||
var batchId = 'operation-info-x-batch-' + Date.now() + '-' + Math.floor(Math.random() * 100000);
|
||||
for (var index = 0; index < renderedPages.length; index += 1) {
|
||||
var rendered = renderedPages[index];
|
||||
postMessage({
|
||||
batchId: batchId,
|
||||
batchIndex: index,
|
||||
batchTotal: renderedPages.length,
|
||||
dataUrl: rendered.canvas.toDataURL('image/png'),
|
||||
fileName: 'operation-info-x-' + formatXPageFileIndex(index) + '-' + rendered.token + '-' + rendered.kind + '-' + timestamp + '.png'
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
postMessage({ error: true });
|
||||
}
|
||||
}
|
||||
|
||||
function appendCaptureSectionLayout(layout, ctx, section, startY, options) {
|
||||
var boxX = options && options.x != null ? options.x : 0;
|
||||
var boxY = startY;
|
||||
@@ -1105,20 +1870,22 @@ const buildOperationPageScript = (layout: {
|
||||
link.onclick = function(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
var updatedAtNode = q('.upd_time', dd);
|
||||
var subTitleNode = q('.delay_subttl', heading);
|
||||
var sectionHtml = getSubsectionHtml(midasi);
|
||||
if (!sectionHtml) {
|
||||
postMessage({ error: true });
|
||||
return;
|
||||
}
|
||||
renderItemToImage(
|
||||
getTitleText(heading),
|
||||
subTitleNode ? subTitleNode.textContent : '',
|
||||
updatedAtNode ? updatedAtNode.textContent : '',
|
||||
sectionHtml,
|
||||
'operation-info-' + infoId + '-section-' + index + '-' + Date.now()
|
||||
);
|
||||
runCaptureAction(function() {
|
||||
var updatedAtNode = q('.upd_time', dd);
|
||||
var subTitleNode = q('.delay_subttl', heading);
|
||||
var sectionHtml = getSubsectionHtml(midasi);
|
||||
if (!sectionHtml) {
|
||||
postMessage({ error: true });
|
||||
return;
|
||||
}
|
||||
return renderItemToImage(
|
||||
getTitleText(heading),
|
||||
subTitleNode ? subTitleNode.textContent : '',
|
||||
updatedAtNode ? updatedAtNode.textContent : '',
|
||||
sectionHtml,
|
||||
'operation-info-' + infoId + '-section-' + index + '-' + Date.now()
|
||||
);
|
||||
});
|
||||
};
|
||||
wrap.appendChild(link);
|
||||
if (midasi.nextSibling) {
|
||||
@@ -1157,7 +1924,9 @@ const buildOperationPageScript = (layout: {
|
||||
allLink.onclick = function(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
renderAllItemsToImage(getOperationItems(), getStatusText(q('.delay_status')), 'operation-info-all-' + Date.now());
|
||||
runCaptureAction(function() {
|
||||
return renderAllItemsToImage(getOperationItems(), getStatusText(q('.delay_status')), 'operation-info-all-' + Date.now());
|
||||
});
|
||||
};
|
||||
wrap.appendChild(allLink);
|
||||
}
|
||||
@@ -1172,13 +1941,31 @@ const buildOperationPageScript = (layout: {
|
||||
batchLink.onclick = function(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
renderAllItemsToSeparateImages(getOperationItems(), 'operation-info-share-' + Date.now());
|
||||
runCaptureAction(function() {
|
||||
return renderAllItemsToSeparateImages(getOperationItems(), 'operation-info-share-' + Date.now());
|
||||
});
|
||||
};
|
||||
wrap.appendChild(batchLink);
|
||||
}
|
||||
} else if (batchLink && batchLink.parentNode) {
|
||||
batchLink.parentNode.removeChild(batchLink);
|
||||
}
|
||||
|
||||
var xLink = q('.jrs-capture-page-link.is-x', wrap);
|
||||
if (!xLink) {
|
||||
xLink = document.createElement('a');
|
||||
xLink.href = '#';
|
||||
xLink.className = 'jrs-capture-page-link is-x';
|
||||
xLink.textContent = 'X投稿向け画像を作成';
|
||||
xLink.onclick = function(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
runCaptureAction(function() {
|
||||
return renderXPostImageSet(getOperationItems(), String(Date.now()));
|
||||
});
|
||||
};
|
||||
wrap.appendChild(xLink);
|
||||
}
|
||||
}
|
||||
|
||||
function removeCaptureButtons() {
|
||||
@@ -1216,21 +2003,44 @@ const buildOperationPageScript = (layout: {
|
||||
link.onclick = function(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
var updatedAtNode = q('.upd_time', dd);
|
||||
var subTitleNode = q('.delay_subttl', heading);
|
||||
if (!detailNode.innerHTML) {
|
||||
postMessage({ error: true });
|
||||
return;
|
||||
}
|
||||
renderItemToImage(
|
||||
getTitleText(heading),
|
||||
subTitleNode ? subTitleNode.textContent : '',
|
||||
updatedAtNode ? updatedAtNode.textContent : '',
|
||||
detailNode.innerHTML,
|
||||
'operation-info-' + infoId + '-' + Date.now()
|
||||
);
|
||||
runCaptureAction(function() {
|
||||
var updatedAtNode = q('.upd_time', dd);
|
||||
var subTitleNode = q('.delay_subttl', heading);
|
||||
if (!detailNode.innerHTML) {
|
||||
postMessage({ error: true });
|
||||
return;
|
||||
}
|
||||
return renderItemToImage(
|
||||
getTitleText(heading),
|
||||
subTitleNode ? subTitleNode.textContent : '',
|
||||
updatedAtNode ? updatedAtNode.textContent : '',
|
||||
detailNode.innerHTML,
|
||||
'operation-info-' + infoId + '-' + Date.now()
|
||||
);
|
||||
});
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -2633,7 +3443,12 @@ export default function tndView() {
|
||||
},
|
||||
});
|
||||
if (payload.error) {
|
||||
Alert.alert("切り出し失敗", "項目画像の生成に失敗しました。");
|
||||
const message = payload.reason === "x-overflow"
|
||||
? "X投稿向け画像は4枚以内に読みやすく収まりませんでした。『項目ごとに複数画像で共有』をご利用ください。"
|
||||
: payload.reason === "x-map"
|
||||
? "路線図の読み込みに失敗したため、X投稿向け画像を生成できませんでした。"
|
||||
: "項目画像の生成に失敗しました。";
|
||||
Alert.alert("切り出し失敗", message);
|
||||
return;
|
||||
}
|
||||
if (payload.batchId) {
|
||||
|
||||
+3
-1
@@ -6,6 +6,7 @@
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"eject": "expo eject",
|
||||
"postinstall": "patch-package",
|
||||
"pushWeb": "npx expo export -p web && netlify deploy --dir dist --prod",
|
||||
"checkDiagram": "bash ./check.sh"
|
||||
},
|
||||
@@ -106,7 +107,8 @@
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.14",
|
||||
"babel-preset-expo": "~55.0.12",
|
||||
"baseline-browser-mapping": "^2.10.9"
|
||||
"baseline-browser-mapping": "^2.10.9",
|
||||
"patch-package": "^8.0.1"
|
||||
},
|
||||
"private": true,
|
||||
"name": "jrshikoku",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
diff --git a/node_modules/@react-navigation/bottom-tabs/src/views/BottomTabView.tsx b/node_modules/@react-navigation/bottom-tabs/src/views/BottomTabView.tsx
|
||||
index ec0491e..c546b88 100644
|
||||
--- a/node_modules/@react-navigation/bottom-tabs/src/views/BottomTabView.tsx
|
||||
+++ b/node_modules/@react-navigation/bottom-tabs/src/views/BottomTabView.tsx
|
||||
@@ -318,7 +318,12 @@ export function BottomTabView(props: Props) {
|
||||
return (
|
||||
<MaybeScreen
|
||||
key={route.key}
|
||||
- style={[StyleSheet.absoluteFill, { zIndex: isFocused ? 0 : -1 }]}
|
||||
+ style={[
|
||||
+ StyleSheet.absoluteFill,
|
||||
+ Platform.OS === 'android'
|
||||
+ ? { opacity: isFocused ? 1 : 0 }
|
||||
+ : { zIndex: isFocused ? 0 : -1 },
|
||||
+ ]}
|
||||
active={activityState}
|
||||
enabled={detachInactiveScreens}
|
||||
freezeOnBlur={freezeOnBlur}
|
||||
+93
-50
@@ -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={{
|
||||
|
||||
@@ -130,7 +130,7 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
|
||||
let nearest: StationProps | null = null;
|
||||
let nearestDist = Infinity;
|
||||
Object.keys(originalStationList).forEach((lineName) => {
|
||||
(originalStationList as any)[lineName]?.forEach((station: StationProps) => {
|
||||
originalStationList[lineName]?.forEach((station) => {
|
||||
const d = _calcDistance({ lat: station.lat, lng: station.lng }, userPos);
|
||||
if (d < nearestDist) {
|
||||
nearestDist = d;
|
||||
|
||||
@@ -10,11 +10,13 @@ import {
|
||||
getStationList,
|
||||
lineList_LineWebID,
|
||||
} from "@/lib/getStationList";
|
||||
import { StationProps } from "@/lib/CommonTypes";
|
||||
import { OriginalStationList, StationProps } from "@/lib/CommonTypes";
|
||||
|
||||
type initialStateType = {
|
||||
originalStationList: StationProps[][];
|
||||
setOriginalStationList: React.Dispatch<React.SetStateAction<StationProps[][]>>;
|
||||
originalStationList: OriginalStationList;
|
||||
setOriginalStationList: React.Dispatch<
|
||||
React.SetStateAction<OriginalStationList>
|
||||
>;
|
||||
getStationDataFromName: (id: string) => StationProps[];
|
||||
getStationDataFromId: (id: string) => StationProps[];
|
||||
getStationDataFromNameBase: (name: string) => StationProps[];
|
||||
@@ -27,7 +29,7 @@ type initialStateType = {
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
originalStationList: [[]],
|
||||
originalStationList: {},
|
||||
setOriginalStationList: () => {},
|
||||
getStationDataFromName: () => [],
|
||||
getStationDataFromId: () => [],
|
||||
@@ -51,7 +53,8 @@ export const useStationList = () => {
|
||||
};
|
||||
|
||||
export const StationListProvider: FC<Props> = ({ children }) => {
|
||||
const [originalStationList, setOriginalStationList] = useState<StationProps[][]>([]);
|
||||
const [originalStationList, setOriginalStationList] =
|
||||
useState<OriginalStationList>({});
|
||||
useEffect(() => {
|
||||
getStationList().then(setOriginalStationList);
|
||||
}, []);
|
||||
@@ -105,9 +108,10 @@ export const StationListProvider: FC<Props> = ({ children }) => {
|
||||
|
||||
const [stationList, setStationList] = useState<StationProps[][]>([[]]);
|
||||
useEffect(() => {
|
||||
if (originalStationList.length === 0) return;
|
||||
if (Object.keys(originalStationList).length === 0) return;
|
||||
const stationList = lineList.map((d) =>
|
||||
originalStationList[d].map((a) => ({
|
||||
...a,
|
||||
StationNumber: a.StationNumber,
|
||||
StationName: a.Station_JP,
|
||||
}))
|
||||
|
||||
@@ -6,16 +6,18 @@ import React, {
|
||||
FC,
|
||||
} from "react";
|
||||
import { lineList, getStationList } from "../lib/getStationList";
|
||||
import { StationProps } from "@/lib/CommonTypes";
|
||||
import { OriginalStationList, StationProps } from "@/lib/CommonTypes";
|
||||
|
||||
type initialStateType = {
|
||||
originalStationList: StationProps[][];
|
||||
setOriginalStationList: React.Dispatch<React.SetStateAction<StationProps[][]>>;
|
||||
originalStationList: OriginalStationList;
|
||||
setOriginalStationList: React.Dispatch<
|
||||
React.SetStateAction<OriginalStationList>
|
||||
>;
|
||||
getStationData: (id: string) => StationProps[];
|
||||
stationList: { StationNumber: string; StationName: string }[][];
|
||||
};
|
||||
const initialState = {
|
||||
originalStationList: [[]],
|
||||
originalStationList: {},
|
||||
setOriginalStationList: () => {},
|
||||
getStationData: () => [],
|
||||
stationList: [],
|
||||
@@ -30,7 +32,8 @@ export const useTopMenu = () => {
|
||||
};
|
||||
|
||||
export const TopMenuProvider: FC<Props> = ({ children }) => {
|
||||
const [originalStationList, setOriginalStationList] = useState<StationProps[][]>([]);
|
||||
const [originalStationList, setOriginalStationList] =
|
||||
useState<OriginalStationList>({});
|
||||
useEffect(() => {
|
||||
getStationList().then(setOriginalStationList);
|
||||
}, []);
|
||||
@@ -47,7 +50,7 @@ export const TopMenuProvider: FC<Props> = ({ children }) => {
|
||||
};
|
||||
const [stationList, setStationList] = useState<{ StationNumber: string; StationName: string }[][]>([[]]);
|
||||
useEffect(()=>{
|
||||
if(originalStationList.length === 0) return;
|
||||
if (Object.keys(originalStationList).length === 0) return;
|
||||
const stationList =
|
||||
originalStationList &&
|
||||
lineList.map((d) =>
|
||||
|
||||
@@ -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;
|
||||
@@ -107,6 +111,8 @@ const initialState = {
|
||||
activeRecording: null as TrainRecording | null,
|
||||
/** 現在再生中のスナップショットインデックス */
|
||||
playbackIndex: 0,
|
||||
/** 再生中スナップショットに対応する基準時刻(通常時は null) */
|
||||
playbackCurrentTimeIso: null as string | null,
|
||||
/** 再生一時停止中か */
|
||||
playbackPaused: false,
|
||||
startRecording: () => {},
|
||||
@@ -158,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]);
|
||||
|
||||
//列車情報表示関連
|
||||
@@ -229,6 +267,13 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
const [recordingList, setRecordingList] = useState<RecordingMeta[]>([]);
|
||||
const [activeRecording, setActiveRecording] = useState<TrainRecording | null>(null);
|
||||
const [playbackIndex, setPlaybackIndex] = useState(0);
|
||||
const playbackCurrentTimeIso =
|
||||
recorderState === 'playing' && activeRecording && activeRecording.snapshots.length > 0
|
||||
? new Date(
|
||||
new Date(activeRecording.recordedAt).getTime() +
|
||||
(activeRecording.snapshots[playbackIndex]?.t ?? 0)
|
||||
).toISOString()
|
||||
: null;
|
||||
const [playbackPaused, setPlaybackPaused] = useState(false);
|
||||
const recordingStartTimeRef = useRef<number>(0);
|
||||
const recordingSnapshotsRef = useRef<Array<{ t: number; trains: TrainEntry[] }>>([]);
|
||||
@@ -549,6 +594,8 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
setTrainMenu,
|
||||
updatePermission,
|
||||
setUpdatePermission,
|
||||
userPermissionRole,
|
||||
restrictedSoundPermission,
|
||||
dataSourcePermission,
|
||||
injectJavascript,
|
||||
injectJavascriptBeforeContentLoaded,
|
||||
@@ -563,6 +610,7 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
recordingList,
|
||||
activeRecording,
|
||||
playbackIndex,
|
||||
playbackCurrentTimeIso,
|
||||
playbackPaused,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
-1
@@ -6,7 +6,8 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": ["./*"],
|
||||
"expo-live-activity": ["./modules/expo-live-activity/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"extends": "expo/tsconfig.base"
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* プロジェクト全体で使用する型を集約
|
||||
*/
|
||||
|
||||
export * from "./operationInfo";
|
||||
|
||||
/**
|
||||
* バス停・駅データの種別
|
||||
*/
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
};
|
||||
@@ -2084,6 +2084,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.7.13.tgz#ff34942667a4e19a9f4a0996a76814daac364cf3"
|
||||
integrity sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==
|
||||
|
||||
"@yarnpkg/lockfile@^1.1.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31"
|
||||
integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==
|
||||
|
||||
abort-controller@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392"
|
||||
@@ -2539,6 +2544,32 @@ cacheable-request@^7.0.2:
|
||||
normalize-url "^6.0.1"
|
||||
responselike "^2.0.0"
|
||||
|
||||
call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6"
|
||||
integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==
|
||||
dependencies:
|
||||
es-errors "^1.3.0"
|
||||
function-bind "^1.1.2"
|
||||
|
||||
call-bind@^1.0.8:
|
||||
version "1.0.9"
|
||||
resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.9.tgz#39a644700c80bc7d0ca9102fc6d1d43b2fd7eee7"
|
||||
integrity sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==
|
||||
dependencies:
|
||||
call-bind-apply-helpers "^1.0.2"
|
||||
es-define-property "^1.0.1"
|
||||
get-intrinsic "^1.3.0"
|
||||
set-function-length "^1.2.2"
|
||||
|
||||
call-bound@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a"
|
||||
integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==
|
||||
dependencies:
|
||||
call-bind-apply-helpers "^1.0.2"
|
||||
get-intrinsic "^1.3.0"
|
||||
|
||||
camelcase@^5.3.1:
|
||||
version "5.3.1"
|
||||
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
|
||||
@@ -2614,7 +2645,7 @@ ci-info@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46"
|
||||
integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==
|
||||
|
||||
ci-info@^3.2.0, ci-info@^3.3.0:
|
||||
ci-info@^3.2.0, ci-info@^3.3.0, ci-info@^3.7.0:
|
||||
version "3.9.0"
|
||||
resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4"
|
||||
integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==
|
||||
@@ -2788,7 +2819,7 @@ cross-fetch@^3.1.5:
|
||||
dependencies:
|
||||
node-fetch "^2.7.0"
|
||||
|
||||
cross-spawn@^7.0.6:
|
||||
cross-spawn@^7.0.3, cross-spawn@^7.0.6:
|
||||
version "7.0.6"
|
||||
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
|
||||
integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
|
||||
@@ -2895,6 +2926,15 @@ defer-to-connect@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587"
|
||||
integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==
|
||||
|
||||
define-data-property@^1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e"
|
||||
integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==
|
||||
dependencies:
|
||||
es-define-property "^1.0.0"
|
||||
es-errors "^1.3.0"
|
||||
gopd "^1.0.1"
|
||||
|
||||
define-lazy-prop@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
|
||||
@@ -2959,6 +2999,15 @@ domutils@^3.0.1:
|
||||
domelementtype "^2.3.0"
|
||||
domhandler "^5.0.3"
|
||||
|
||||
dunder-proto@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
|
||||
integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==
|
||||
dependencies:
|
||||
call-bind-apply-helpers "^1.0.1"
|
||||
es-errors "^1.3.0"
|
||||
gopd "^1.2.0"
|
||||
|
||||
eastasianwidth@^0.2.0:
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
|
||||
@@ -3020,11 +3069,23 @@ error-stack-parser@^2.0.6:
|
||||
dependencies:
|
||||
stackframe "^1.3.4"
|
||||
|
||||
es-define-property@^1.0.0, es-define-property@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa"
|
||||
integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==
|
||||
|
||||
es-errors@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
|
||||
integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
|
||||
|
||||
es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz#a2d0b373205724dfa525d23b0c3e1b1ca582c99b"
|
||||
integrity sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==
|
||||
dependencies:
|
||||
es-errors "^1.3.0"
|
||||
|
||||
escalade@^3.1.1, escalade@^3.2.0:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5"
|
||||
@@ -3463,6 +3524,13 @@ find-up@^7.0.0:
|
||||
path-exists "^5.0.0"
|
||||
unicorn-magic "^0.1.0"
|
||||
|
||||
find-yarn-workspace-root@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz#f47fb8d239c900eb78179aa81b66673eac88f7bd"
|
||||
integrity sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==
|
||||
dependencies:
|
||||
micromatch "^4.0.2"
|
||||
|
||||
flow-enums-runtime@^0.0.6:
|
||||
version "0.0.6"
|
||||
resolved "https://registry.yarnpkg.com/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz#5bb0cd1b0a3e471330f4d109039b7eba5cb3e787"
|
||||
@@ -3486,6 +3554,15 @@ fresh@~0.5.2:
|
||||
resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
|
||||
integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==
|
||||
|
||||
fs-extra@^10.0.0:
|
||||
version "10.1.0"
|
||||
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf"
|
||||
integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==
|
||||
dependencies:
|
||||
graceful-fs "^4.2.0"
|
||||
jsonfile "^6.0.1"
|
||||
universalify "^2.0.0"
|
||||
|
||||
fs.realpath@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
|
||||
@@ -3511,11 +3588,35 @@ get-caller-file@^2.0.5:
|
||||
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
|
||||
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
|
||||
|
||||
get-intrinsic@^1.2.4, get-intrinsic@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01"
|
||||
integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==
|
||||
dependencies:
|
||||
call-bind-apply-helpers "^1.0.2"
|
||||
es-define-property "^1.0.1"
|
||||
es-errors "^1.3.0"
|
||||
es-object-atoms "^1.1.1"
|
||||
function-bind "^1.1.2"
|
||||
get-proto "^1.0.1"
|
||||
gopd "^1.2.0"
|
||||
has-symbols "^1.1.0"
|
||||
hasown "^2.0.2"
|
||||
math-intrinsics "^1.1.0"
|
||||
|
||||
get-package-type@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a"
|
||||
integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==
|
||||
|
||||
get-proto@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1"
|
||||
integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==
|
||||
dependencies:
|
||||
dunder-proto "^1.0.1"
|
||||
es-object-atoms "^1.0.0"
|
||||
|
||||
get-stream@^5.1.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3"
|
||||
@@ -3561,6 +3662,11 @@ glob@^7.1.1, glob@^7.1.3, glob@^7.1.4:
|
||||
once "^1.3.0"
|
||||
path-is-absolute "^1.0.0"
|
||||
|
||||
gopd@^1.0.1, gopd@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
|
||||
integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==
|
||||
|
||||
got@^11.5.1:
|
||||
version "11.8.6"
|
||||
resolved "https://registry.yarnpkg.com/got/-/got-11.8.6.tgz#276e827ead8772eddbcfc97170590b841823233a"
|
||||
@@ -3578,7 +3684,7 @@ got@^11.5.1:
|
||||
p-cancelable "^2.0.0"
|
||||
responselike "^2.0.0"
|
||||
|
||||
graceful-fs@^4.2.4, graceful-fs@^4.2.9:
|
||||
graceful-fs@^4.1.11, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.9:
|
||||
version "4.2.11"
|
||||
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
|
||||
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
|
||||
@@ -3600,7 +3706,19 @@ has-flag@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
|
||||
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
|
||||
|
||||
hasown@^2.0.3:
|
||||
has-property-descriptors@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854"
|
||||
integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==
|
||||
dependencies:
|
||||
es-define-property "^1.0.0"
|
||||
|
||||
has-symbols@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338"
|
||||
integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==
|
||||
|
||||
hasown@^2.0.2, hasown@^2.0.3:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003"
|
||||
integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==
|
||||
@@ -3841,6 +3959,11 @@ is-wsl@^2.1.1, is-wsl@^2.2.0:
|
||||
dependencies:
|
||||
is-docker "^2.0.0"
|
||||
|
||||
isarray@^2.0.5:
|
||||
version "2.0.5"
|
||||
resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723"
|
||||
integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==
|
||||
|
||||
isexe@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
||||
@@ -4010,11 +4133,36 @@ [email protected]:
|
||||
resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
|
||||
integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==
|
||||
|
||||
json-stable-stringify@^1.0.2:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz#8903cfac42ea1a0f97f35d63a4ce0518f0cc6a70"
|
||||
integrity sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==
|
||||
dependencies:
|
||||
call-bind "^1.0.8"
|
||||
call-bound "^1.0.4"
|
||||
isarray "^2.0.5"
|
||||
jsonify "^0.0.1"
|
||||
object-keys "^1.1.1"
|
||||
|
||||
json5@^2.2.3:
|
||||
version "2.2.3"
|
||||
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
|
||||
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
|
||||
|
||||
jsonfile@^6.0.1:
|
||||
version "6.2.1"
|
||||
resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.2.1.tgz#b6e31717f22cc37330b081ce0051ed5de53af2f6"
|
||||
integrity sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==
|
||||
dependencies:
|
||||
universalify "^2.0.0"
|
||||
optionalDependencies:
|
||||
graceful-fs "^4.1.6"
|
||||
|
||||
jsonify@^0.0.1:
|
||||
version "0.0.1"
|
||||
resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.1.tgz#2aa3111dae3d34a0f151c63f3a45d995d9420978"
|
||||
integrity sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==
|
||||
|
||||
keyv@^4.0.0:
|
||||
version "4.5.4"
|
||||
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
|
||||
@@ -4022,6 +4170,13 @@ keyv@^4.0.0:
|
||||
dependencies:
|
||||
json-buffer "3.0.1"
|
||||
|
||||
klaw-sync@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/klaw-sync/-/klaw-sync-6.0.0.tgz#1fd2cfd56ebb6250181114f0a581167099c2b28c"
|
||||
integrity sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==
|
||||
dependencies:
|
||||
graceful-fs "^4.1.11"
|
||||
|
||||
kleur@^3.0.3:
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
|
||||
@@ -4201,6 +4356,11 @@ marky@^1.2.2:
|
||||
resolved "https://registry.yarnpkg.com/marky/-/marky-1.3.0.tgz#422b63b0baf65022f02eda61a238eccdbbc14997"
|
||||
integrity sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==
|
||||
|
||||
math-intrinsics@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
|
||||
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
|
||||
|
||||
[email protected]:
|
||||
version "2.0.14"
|
||||
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50"
|
||||
@@ -4420,7 +4580,7 @@ [email protected], metro@^0.83.6:
|
||||
ws "^7.5.10"
|
||||
yargs "^17.6.2"
|
||||
|
||||
micromatch@^4.0.4:
|
||||
micromatch@^4.0.2, micromatch@^4.0.4:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
|
||||
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
|
||||
@@ -4498,6 +4658,11 @@ [email protected]:
|
||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
|
||||
integrity sha512-7Wl+Jz+IGWuSdgsQEJ4JunV0si/iMhg42MnQQG6h1R6TNeVenp4U9x5CC5v/gYqz/fENLQITAWXidNtVL0NNbw==
|
||||
|
||||
minimist@^1.2.6:
|
||||
version "1.2.8"
|
||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
|
||||
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
|
||||
|
||||
"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2, minipass@^7.1.3:
|
||||
version "7.1.3"
|
||||
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b"
|
||||
@@ -4622,6 +4787,11 @@ object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
|
||||
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
|
||||
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
|
||||
|
||||
object-keys@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
|
||||
integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
|
||||
|
||||
on-finished@~2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
|
||||
@@ -4655,7 +4825,7 @@ onetime@^2.0.0:
|
||||
dependencies:
|
||||
mimic-fn "^1.0.0"
|
||||
|
||||
open@^7.0.3:
|
||||
open@^7.0.3, open@^7.4.2:
|
||||
version "7.4.2"
|
||||
resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321"
|
||||
integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==
|
||||
@@ -4769,6 +4939,26 @@ parseurl@~1.3.3:
|
||||
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
|
||||
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
|
||||
|
||||
patch-package@^8.0.1:
|
||||
version "8.0.1"
|
||||
resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-8.0.1.tgz#79d02f953f711e06d1f8949c8a13e5d3d7ba1a60"
|
||||
integrity sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==
|
||||
dependencies:
|
||||
"@yarnpkg/lockfile" "^1.1.0"
|
||||
chalk "^4.1.2"
|
||||
ci-info "^3.7.0"
|
||||
cross-spawn "^7.0.3"
|
||||
find-yarn-workspace-root "^2.0.0"
|
||||
fs-extra "^10.0.0"
|
||||
json-stable-stringify "^1.0.2"
|
||||
klaw-sync "^6.0.0"
|
||||
minimist "^1.2.6"
|
||||
open "^7.4.2"
|
||||
semver "^7.5.3"
|
||||
slash "^2.0.0"
|
||||
tmp "^0.2.4"
|
||||
yaml "^2.2.2"
|
||||
|
||||
path-exists@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
|
||||
@@ -5364,7 +5554,7 @@ semver@^6.3.0, semver@^6.3.1:
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
|
||||
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
|
||||
|
||||
semver@^7.1.3, semver@^7.3.5, semver@^7.5.4, semver@^7.6.0:
|
||||
semver@^7.1.3, semver@^7.3.5, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0:
|
||||
version "7.8.5"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69"
|
||||
integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==
|
||||
@@ -5403,6 +5593,18 @@ serve-static@^1.16.2:
|
||||
parseurl "~1.3.3"
|
||||
send "~0.19.1"
|
||||
|
||||
set-function-length@^1.2.2:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449"
|
||||
integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==
|
||||
dependencies:
|
||||
define-data-property "^1.1.4"
|
||||
es-errors "^1.3.0"
|
||||
function-bind "^1.1.2"
|
||||
get-intrinsic "^1.2.4"
|
||||
gopd "^1.0.1"
|
||||
has-property-descriptors "^1.0.2"
|
||||
|
||||
setimmediate@^1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
|
||||
@@ -5466,6 +5668,11 @@ sisteransi@^1.0.5:
|
||||
resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed"
|
||||
integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==
|
||||
|
||||
slash@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44"
|
||||
integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==
|
||||
|
||||
slash@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
|
||||
@@ -5730,6 +5937,11 @@ tmp@^0.0.33:
|
||||
dependencies:
|
||||
os-tmpdir "~1.0.2"
|
||||
|
||||
tmp@^0.2.4:
|
||||
version "0.2.7"
|
||||
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.7.tgz#26f4db11d1601ce8012dcb8a798ece1c06a99059"
|
||||
integrity sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==
|
||||
|
||||
[email protected]:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc"
|
||||
@@ -5820,6 +6032,11 @@ unicorn-magic@^0.1.0:
|
||||
resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.1.0.tgz#1bb9a51c823aaf9d73a8bfcd3d1a23dde94b0ce4"
|
||||
integrity sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==
|
||||
|
||||
universalify@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d"
|
||||
integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==
|
||||
|
||||
unpipe@~1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
|
||||
@@ -6030,7 +6247,7 @@ yaml@^1.10.0:
|
||||
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.3.tgz#76e407ed95c42684fb8e14641e5de62fe65bbcb3"
|
||||
integrity sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==
|
||||
|
||||
yaml@^2.6.1:
|
||||
yaml@^2.2.2, yaml@^2.6.1:
|
||||
version "2.9.0"
|
||||
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.0.tgz#78274afd93598a1dfdd6130df6a566defcbf9aa4"
|
||||
integrity sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==
|
||||
|
||||
Reference in New Issue
Block a user