Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6e6d2e809 | |||
| 9eff185351 | |||
| 10e9aece2b | |||
| a1b9144851 | |||
| 16a62aa3ba | |||
| f8395d7270 | |||
| 5a0fb9f43c | |||
| 9d188198a9 | |||
| cb1d2665d8 | |||
| 3bbdd9d0a6 | |||
| 159f91e55e | |||
| daf261b4f8 | |||
| 50fa4de919 | |||
| ab2061b2bd | |||
| 4dd0775640 | |||
| 4f81431ccd |
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { NavigationContainer, DarkTheme, DefaultTheme } from "@react-navigation/native";
|
||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||
import { Animated, Platform, ActivityIndicator, View, StyleSheet, StatusBar } from "react-native";
|
||||
import { Animated, Platform, ActivityIndicator, View, StyleSheet, StatusBar, useWindowDimensions } from "react-native";
|
||||
import { useNavigationState } from "@react-navigation/native";
|
||||
import { useFonts } from "expo-font";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
@@ -38,6 +38,8 @@ const Tab = createBottomTabNavigator<RootTabParamList>();
|
||||
export function AppContainer() {
|
||||
const { areaInfo, areaIconBadgeText, isInfo } = useAreaInfo();
|
||||
const { selectedLine } = useTrainMenu();
|
||||
const { width, height } = useWindowDimensions();
|
||||
const operationScreenKey = width > height ? "operation-landscape" : "operation-portrait";
|
||||
const [isExtraWindowOpen, setIsExtraWindowOpen] = React.useState(false);
|
||||
|
||||
// フェードアニメーション用 (0=通常, 1=追加ウィンドウ青)
|
||||
@@ -185,8 +187,9 @@ export function AppContainer() {
|
||||
areaInfo ? areaIconBadgeText : undefined,
|
||||
isInfo
|
||||
)}
|
||||
children={TNDView}
|
||||
/>
|
||||
>
|
||||
{() => <TNDView key={operationScreenKey} />}
|
||||
</Tab.Screen>
|
||||
</Tab.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useUnyohub } from "@/stateBox/useUnyohub";
|
||||
import { useElesite } from "@/stateBox/useElesite";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { useResponsive } from "@/lib/responsive";
|
||||
import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
|
||||
|
||||
type Props = {
|
||||
data: { trainNum: string; limited: string };
|
||||
@@ -47,11 +48,11 @@ export const HeaderText: FC<Props> = ({
|
||||
from,
|
||||
scrollRef,
|
||||
}) => {
|
||||
const { limited, trainNum } = data;
|
||||
const { trainNum } = data;
|
||||
|
||||
const { fixed } = useThemeColors();
|
||||
const { fontScale } = useResponsive();
|
||||
const { updatePermission } = useTrainMenu();
|
||||
const { iconSetting } = useTrainMenu();
|
||||
const { allCustomTrainData, getTodayOperationByTrainId } =
|
||||
useAllTrainDiagram();
|
||||
const { expoPushToken } = useNotification();
|
||||
@@ -63,15 +64,14 @@ export const HeaderText: FC<Props> = ({
|
||||
} = useUnyohub();
|
||||
const { getElesiteEntriesByTrainNumber, useElesite: elesiteEnabled } =
|
||||
useElesite();
|
||||
const iconDisplayMode = normalizeIconDisplayMode(iconSetting);
|
||||
|
||||
// 追加ソースのON/OFFをここで管理(将来ソースが増えたらここに足す)
|
||||
const additionalSources = {
|
||||
unyohub: unyohubEnabled,
|
||||
elesite: elesiteEnabled,
|
||||
};
|
||||
const hasAdditionalSources = Object.values(additionalSources).some(Boolean);
|
||||
|
||||
// 列車名、種別、フォントの取得
|
||||
const [
|
||||
typeName,
|
||||
trainName,
|
||||
@@ -133,7 +133,7 @@ export const HeaderText: FC<Props> = ({
|
||||
case to_data && to_data !== "":
|
||||
return [
|
||||
typeString,
|
||||
to_data + "行き",
|
||||
`${to_data}行き`,
|
||||
fontAvailable,
|
||||
isOneMan,
|
||||
infogram,
|
||||
@@ -147,7 +147,7 @@ export const HeaderText: FC<Props> = ({
|
||||
return [
|
||||
typeString,
|
||||
migrateTrainName(
|
||||
trainData[trainData.length - 1].split(",")[0] + "行き",
|
||||
`${trainData[trainData.length - 1].split(",")[0]}行き`,
|
||||
),
|
||||
fontAvailable,
|
||||
isOneMan,
|
||||
@@ -161,7 +161,7 @@ export const HeaderText: FC<Props> = ({
|
||||
}
|
||||
}, [trainData, trainNum, allCustomTrainData]);
|
||||
|
||||
const allTodayOperation = getTodayOperationByTrainId(trainNum);
|
||||
const allTodayOperation = getTodayOperationByTrainId(trainNum) ?? [];
|
||||
const todayOperation = allTodayOperation.filter((d) => d.state !== 100);
|
||||
|
||||
let iconTrainDirection =
|
||||
@@ -198,7 +198,6 @@ export const HeaderText: FC<Props> = ({
|
||||
: getUnyohubEntriesByTrainNumber(unyohubTrainNumForSourceScreen);
|
||||
const elesiteEntries = getElesiteEntriesByTrainNumber(trainNum);
|
||||
|
||||
// 車番(formations) がある場合のみ「運用Hub情報あり」と判定
|
||||
const hasUnyohubFormation = unyohubEntries.some(
|
||||
(e) => !!e.formations && e.formations.trim() !== "",
|
||||
);
|
||||
@@ -208,12 +207,21 @@ export const HeaderText: FC<Props> = ({
|
||||
|
||||
const hasExtraInfo =
|
||||
priority > 200 ||
|
||||
todayOperation?.length > 0 ||
|
||||
todayOperation.length > 0 ||
|
||||
hasUnyohubFormation ||
|
||||
hasElesiteFormation;
|
||||
|
||||
const [isWrapped, setIsWrapped] = useState(false);
|
||||
|
||||
const openTrainInfoUrl = () => {
|
||||
if (!trainInfoUrl) return;
|
||||
const uri = trainInfoUrl.includes("pdf")
|
||||
? getPDFViewURL(trainInfoUrl)
|
||||
: trainInfoUrl;
|
||||
navigate("generalWebView", { uri, useExitButton: true });
|
||||
SheetManager.hide("EachTrainInfo");
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -232,6 +240,7 @@ export const HeaderText: FC<Props> = ({
|
||||
from={from}
|
||||
todayOperation={todayOperation}
|
||||
direction={iconTrainDirection}
|
||||
iconDisplayMode={iconDisplayMode}
|
||||
/>
|
||||
|
||||
<View
|
||||
@@ -270,26 +279,21 @@ export const HeaderText: FC<Props> = ({
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
onPress={() => {
|
||||
if (!trainInfoUrl) return;
|
||||
const uri = trainInfoUrl.includes("pdf")
|
||||
? getPDFViewURL(trainInfoUrl)
|
||||
: trainInfoUrl;
|
||||
navigate("generalWebView", { uri, useExitButton: true });
|
||||
SheetManager.hide("EachTrainInfo");
|
||||
}}
|
||||
onPress={openTrainInfoUrl}
|
||||
disabled={!trainInfoUrl}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
...textConfig,
|
||||
color: fixed.textOnPrimary,
|
||||
...(trainName.length > 10 ? { fontSize: fontScale(16) } : { fontSize: fontScale(17) }),
|
||||
...(trainName.length > 10
|
||||
? { fontSize: fontScale(16) }
|
||||
: { fontSize: fontScale(17) }),
|
||||
flexShrink: 1,
|
||||
}}
|
||||
onTextLayout={(e) => {
|
||||
if (e.nativeEvent.lines.length > 1) setIsWrapped(true);
|
||||
}}
|
||||
if (e.nativeEvent.lines.length > 1) setIsWrapped(true);
|
||||
}}
|
||||
>
|
||||
{trainName}
|
||||
</Text>
|
||||
@@ -328,7 +332,6 @@ export const HeaderText: FC<Props> = ({
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 追加ソースが全てオフ → 元の挙動(直接 DB ページを開く)
|
||||
const uri = `https://jr-shikoku-data-system.pages.dev/trainData/${trainNum}?userID=${expoPushToken}&from=eachTrainInfo`;
|
||||
navigate("generalWebView", { uri, useExitButton: false });
|
||||
SheetManager.hide("EachTrainInfo");
|
||||
|
||||
@@ -3,13 +3,14 @@ import { Text } from "react-native";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
type props = {
|
||||
infogram: string;
|
||||
fontSize?: number;
|
||||
}
|
||||
export const InfogramText: FC<props> = ({infogram}) => {
|
||||
export const InfogramText: FC<props> = ({infogram, fontSize = 20}) => {
|
||||
const { fixed } = useThemeColors();
|
||||
return (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontSize,
|
||||
color: fixed.textOnPrimary,
|
||||
fontFamily: "JNR-font",
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import React, { ComponentProps, FC } from "react";
|
||||
import { Image, TouchableOpacity, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { TrainIconEntry } from "@/lib/trainIconEntries";
|
||||
|
||||
type StackVariant = "header" | "list";
|
||||
type StatusIcon = ComponentProps<typeof Ionicons>;
|
||||
|
||||
type Props = {
|
||||
entries: TrainIconEntry[];
|
||||
direction?: boolean;
|
||||
hidden?: boolean;
|
||||
onPressEntry?: (entry: TrainIconEntry, index: number) => void;
|
||||
statusIcon?: StatusIcon;
|
||||
variant?: StackVariant;
|
||||
};
|
||||
|
||||
const iconSize = {
|
||||
header: {
|
||||
width: 24,
|
||||
height: 30,
|
||||
stackedWidth: 12,
|
||||
stackedHeight: 15,
|
||||
marginRight: 5,
|
||||
stackedMarginLeft: -10,
|
||||
stackedMarginTop: 10,
|
||||
statusSize: 24,
|
||||
},
|
||||
list: {
|
||||
width: 20,
|
||||
height: 22,
|
||||
stackedWidth: 10,
|
||||
stackedHeight: 12,
|
||||
marginRight: 2,
|
||||
stackedMarginLeft: -8,
|
||||
stackedMarginTop: 8,
|
||||
statusSize: 18,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const TrainIconStack: FC<Props> = ({
|
||||
entries,
|
||||
direction,
|
||||
hidden = false,
|
||||
onPressEntry,
|
||||
statusIcon,
|
||||
variant = "header",
|
||||
}) => {
|
||||
const size = iconSize[variant];
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: "row", alignItems: "flex-start" }}>
|
||||
{entries.map((entry, index) => {
|
||||
const trainIcon = direction
|
||||
? entry.vehicle_info_img
|
||||
: entry.vehicle_info_right_img || entry.vehicle_info_img;
|
||||
if (!trainIcon) return null;
|
||||
|
||||
const content = (
|
||||
<View>
|
||||
<View style={{ opacity: hidden ? 0 : 1 }}>
|
||||
<Image
|
||||
source={{ uri: trainIcon }}
|
||||
style={{
|
||||
height: index > 0 ? size.stackedHeight : size.height,
|
||||
width: index > 0 ? size.stackedWidth : size.width,
|
||||
marginRight: size.marginRight,
|
||||
marginLeft: index > 0 ? size.stackedMarginLeft : 0,
|
||||
marginTop: index > 0 ? size.stackedMarginTop : 0,
|
||||
}}
|
||||
resizeMethod="resize"
|
||||
/>
|
||||
</View>
|
||||
{statusIcon && hidden && (
|
||||
<View style={{ position: "absolute", top: 0, left: 0 }}>
|
||||
<Ionicons {...statusIcon} size={size.statusSize} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!onPressEntry) {
|
||||
return <View key={`${trainIcon}-${index}`}>{content}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={`${trainIcon}-${index}`}
|
||||
onPress={() => onPressEntry(entry, index)}
|
||||
disabled={!entry.vehicle_info_url}
|
||||
>
|
||||
{content}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { ComponentProps, FC, useEffect, useState } from "react";
|
||||
import { View, Image, TouchableOpacity } from "react-native";
|
||||
import React, { ComponentProps, FC, useEffect, useMemo, useState } from "react";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import dayjs from "dayjs";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
@@ -8,6 +7,9 @@ import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
|
||||
import { useInterval } from "@/lib/useInterval";
|
||||
import type { NavigateFunction } from "@/types";
|
||||
import { OperationLogs } from "@/lib/CommonTypes";
|
||||
import type { IconDisplayMode } from "@/lib/iconDisplayMode";
|
||||
import { resolveTrainIconEntries } from "@/lib/trainIconEntries";
|
||||
import { TrainIconStack } from "./TrainIconStack";
|
||||
|
||||
type GlyphNames = ComponentProps<typeof Ionicons>["name"];
|
||||
|
||||
@@ -17,94 +19,54 @@ type Props = {
|
||||
from: string;
|
||||
todayOperation: OperationLogs[];
|
||||
direction?: boolean;
|
||||
iconDisplayMode: IconDisplayMode;
|
||||
};
|
||||
type apt = {
|
||||
name: GlyphNames;
|
||||
color: string;
|
||||
};
|
||||
export const TrainIconStatus: FC<Props> = (props) => {
|
||||
const { data, navigate, from, todayOperation, direction } = props;
|
||||
const {
|
||||
data,
|
||||
navigate,
|
||||
from,
|
||||
todayOperation,
|
||||
direction,
|
||||
iconDisplayMode,
|
||||
} = props;
|
||||
const [anpanmanStatus, setAnpanmanStatus] = useState<apt>();
|
||||
const { allCustomTrainData } = useAllTrainDiagram();
|
||||
const [trainIconData, setTrainIcon] = useState<
|
||||
{ vehicle_info_img: string;vehicle_info_right_img: string; vehicle_info_url: string }[]
|
||||
>([]);
|
||||
const customTrainData = useMemo(
|
||||
() => customTrainDataDetector(data.trainNum, allCustomTrainData),
|
||||
[data.trainNum, allCustomTrainData],
|
||||
);
|
||||
const trainIconData = useMemo(
|
||||
() =>
|
||||
data.trainNum
|
||||
? resolveTrainIconEntries({
|
||||
trainNum: data.trainNum,
|
||||
customTrainData,
|
||||
todayOperation,
|
||||
iconDisplayMode,
|
||||
})
|
||||
: [],
|
||||
[data.trainNum, customTrainData, todayOperation, iconDisplayMode],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data.trainNum) return;
|
||||
const { train_info_img: vehicle_info_img, vehicle_info_url } =
|
||||
customTrainDataDetector(data.trainNum, allCustomTrainData);
|
||||
if (todayOperation.length !== 0) {
|
||||
const returnData =
|
||||
todayOperation
|
||||
.sort((a, b) => {
|
||||
// trainIdからカンマ以降の数字を抽出する関数
|
||||
const extractOrderNumber = (trainId: string): number => {
|
||||
const parts = trainId.split(',');
|
||||
if (parts.length > 1) {
|
||||
const num = parseInt(parts[1].trim(), 10);
|
||||
return isNaN(num) ? Infinity : num;
|
||||
}
|
||||
return Infinity; // カンマなし = 末尾に移動
|
||||
};
|
||||
|
||||
// data.trainNumと一致するtrainIdを探す関数
|
||||
const findMatchingTrainId = (operation: OperationLogs): string | null => {
|
||||
const allTrainIds = [
|
||||
...(operation.train_ids || []),
|
||||
...(operation.related_train_ids || []),
|
||||
];
|
||||
|
||||
// data.trainNumの接頭辞と一致するものを探す
|
||||
for (const trainId of allTrainIds) {
|
||||
const prefix = trainId.split(',')[0]; // カンマ前の部分
|
||||
if (prefix === data.trainNum) {
|
||||
return trainId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const aTrainId = findMatchingTrainId(a);
|
||||
const bTrainId = findMatchingTrainId(b);
|
||||
|
||||
// マッチしたものがない場合は元の順序を保持
|
||||
if (!aTrainId || !bTrainId) {
|
||||
return aTrainId ? -1 : bTrainId ? 1 : 0;
|
||||
}
|
||||
|
||||
const aOrder = extractOrderNumber(aTrainId);
|
||||
const bOrder = extractOrderNumber(bTrainId);
|
||||
|
||||
return aOrder - bOrder;
|
||||
})
|
||||
.map((op) => ({
|
||||
vehicle_info_img: op.vehicle_img || vehicle_info_img,
|
||||
vehicle_info_right_img: op.vehicle_img_right || op.vehicle_img || vehicle_info_img,
|
||||
vehicle_info_url: op.vehicle_info_url,
|
||||
})) || [];
|
||||
setTrainIcon(returnData);
|
||||
} else if (vehicle_info_img) {
|
||||
setTrainIcon([{ vehicle_info_img, vehicle_info_right_img: vehicle_info_img, vehicle_info_url }]);
|
||||
}
|
||||
|
||||
// アンパンマンステータスAPIのエンドポイント判定
|
||||
let anpanmanApiPath: string | null = null;
|
||||
switch (data.trainNum) {
|
||||
// 予讃線 → yosan-anpanman
|
||||
// しおかぜ 8000 アンパン
|
||||
case "10M":
|
||||
case "22M":
|
||||
case "9M":
|
||||
case "21M":
|
||||
// いしづち 8000 アンパン
|
||||
case "1010M":
|
||||
case "1022M":
|
||||
case "1009M":
|
||||
case "1021M":
|
||||
// いしづち 三桁 アンパン
|
||||
case "1041M":
|
||||
case "1044M":
|
||||
// 宇和海 2000 アンパン
|
||||
case "1058D":
|
||||
case "1066D":
|
||||
case "1074D":
|
||||
@@ -113,8 +75,6 @@ export const TrainIconStatus: FC<Props> = (props) => {
|
||||
case "1067D":
|
||||
anpanmanApiPath = "yosan-anpanman";
|
||||
break;
|
||||
// 土讃線 → dosan-anpanman
|
||||
// 南風 2700 アンパン
|
||||
case "32D":
|
||||
case "36D":
|
||||
case "44D":
|
||||
@@ -132,12 +92,12 @@ export const TrainIconStatus: FC<Props> = (props) => {
|
||||
fetch(
|
||||
`https://n8n.haruk.in/webhook/${anpanmanApiPath}?trainNum=${
|
||||
data.trainNum
|
||||
}&month=${dayjs().format("M")}&day=${dayjs().format("D")}`,{ cache: "no-store" }
|
||||
}&month=${dayjs().format("M")}&day=${dayjs().format("D")}`,
|
||||
{ cache: "no-store" },
|
||||
)
|
||||
.then((d) => d.json())
|
||||
.then((d) => {
|
||||
if (d.trainStatus == "〇" || d.trainStatus == "○") {
|
||||
//setAnpanmanStatus({name:"checkmark-circle-outline",color:"blue"});
|
||||
} else if (d.trainStatus == "▲") {
|
||||
setAnpanmanStatus({ name: "warning-outline", color: "yellow" });
|
||||
} else if (d.trainStatus == "×") {
|
||||
@@ -146,11 +106,8 @@ export const TrainIconStatus: FC<Props> = (props) => {
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [data.trainNum, allCustomTrainData, todayOperation]);
|
||||
}, [data.trainNum, allCustomTrainData, todayOperation, iconDisplayMode]);
|
||||
|
||||
// JSスレッドでの点滅(useInterval + useState)
|
||||
// reanimated の withRepeat はUIスレッドで毎フレーム更新し続けるため
|
||||
// ActionSheetのスプリングアニメーションと競合する
|
||||
const [showIcon, setShowIcon] = useState(false);
|
||||
useInterval(() => {
|
||||
if (anpanmanStatus) {
|
||||
@@ -159,47 +116,18 @@ export const TrainIconStatus: FC<Props> = (props) => {
|
||||
}, 1000, !!anpanmanStatus);
|
||||
|
||||
return (
|
||||
<>
|
||||
{trainIconData.map(
|
||||
({ vehicle_info_img: trainIcon, vehicle_info_right_img: trainIconRight, vehicle_info_url: address }, index) => (
|
||||
<TouchableOpacity
|
||||
key={`${trainIcon}-${index}`}
|
||||
onPress={() => {
|
||||
navigate("howto", {
|
||||
info: address,
|
||||
goTo: from == "LED" ? "menu" : from,
|
||||
});
|
||||
SheetManager.hide("EachTrainInfo");
|
||||
}}
|
||||
disabled={!address}
|
||||
>
|
||||
<View>
|
||||
<View style={{ opacity: anpanmanStatus && showIcon ? 0 : 1 }}>
|
||||
<Image
|
||||
source={{ uri: direction ? trainIcon : trainIconRight || trainIcon }}
|
||||
style={{
|
||||
height: index > 0 ? 15 : 30,
|
||||
width: index > 0 ? 12 : 24,
|
||||
marginRight: 5,
|
||||
marginLeft: index > 0 ? -10 : 0,
|
||||
marginTop: index > 0 ? 10 : 0,
|
||||
}}
|
||||
resizeMethod="resize"
|
||||
/>
|
||||
</View>
|
||||
{anpanmanStatus && showIcon && (
|
||||
<View style={{ position: "absolute", top: 0, left: 0 }}>
|
||||
<Ionicons
|
||||
{...anpanmanStatus}
|
||||
size={24}
|
||||
style={{ marginRight: 5 }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
<TrainIconStack
|
||||
entries={trainIconData}
|
||||
direction={direction}
|
||||
hidden={!!anpanmanStatus && showIcon}
|
||||
statusIcon={anpanmanStatus}
|
||||
onPressEntry={(entry) => {
|
||||
navigate("howto", {
|
||||
info: entry.vehicle_info_url,
|
||||
goTo: from == "LED" ? "menu" : from,
|
||||
});
|
||||
SheetManager.hide("EachTrainInfo");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
TextInput,
|
||||
ScrollView,
|
||||
Linking,
|
||||
Image,
|
||||
} from "react-native";
|
||||
import { useAllTrainDiagram } from "../stateBox/useAllTrainDiagram";
|
||||
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
|
||||
@@ -24,10 +23,17 @@ import { Switch } from "@rneui/themed";
|
||||
import { migrateTrainName } from "@/lib/eachTrainInfoCoreLib/migrateTrainName";
|
||||
import { OneManText } from "./ActionSheetComponents/EachTrainInfoCore/HeaderTextParts/OneManText";
|
||||
import { getStringConfig } from "@/lib/getStringConfig";
|
||||
import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||||
import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
|
||||
import { InfogramText } from "./ActionSheetComponents/EachTrainInfoCore/HeaderTextParts/InfogramText";
|
||||
import { resolveTrainIconEntries } from "@/lib/trainIconEntries";
|
||||
import { TrainIconStack } from "./ActionSheetComponents/EachTrainInfoCore/TrainIconStack";
|
||||
|
||||
export const AllTrainDiagramView: FC = () => {
|
||||
const { colors, fixed } = useThemeColors();
|
||||
const { goBack, navigate } = useNavigation<any>();
|
||||
const { iconSetting } = useTrainMenu();
|
||||
const iconDisplayMode = normalizeIconDisplayMode(iconSetting);
|
||||
const tabBarHeight = useBottomTabBarHeight();
|
||||
const {
|
||||
keyList,
|
||||
@@ -80,9 +86,27 @@ export const AllTrainDiagramView: FC = () => {
|
||||
openTrainInfo: (d: string) => void;
|
||||
};
|
||||
const Item: FC<ItemProps> = ({ id, openTrainInfo }) => {
|
||||
const { train_info_img, train_name, type, train_num_distance, to_data } =
|
||||
customTrainDataDetector(id, allCustomTrainData);
|
||||
const todayOperation = getTodayOperationByTrainId(id).filter(d=> d.state !== 100);
|
||||
const customTrainData = customTrainDataDetector(id, allCustomTrainData);
|
||||
const {
|
||||
train_name,
|
||||
type,
|
||||
train_num_distance,
|
||||
to_data,
|
||||
infogram,
|
||||
directions,
|
||||
} = customTrainData;
|
||||
const todayOperation = (getTodayOperationByTrainId(id) ?? []).filter(d=> d.state !== 100);
|
||||
const trainIconData = resolveTrainIconEntries({
|
||||
trainNum: id,
|
||||
customTrainData,
|
||||
todayOperation,
|
||||
iconDisplayMode,
|
||||
});
|
||||
let iconTrainDirection =
|
||||
parseInt(id.replace(/[^\d]/g, "")) % 2 == 0 ? true : false;
|
||||
if (directions != undefined) {
|
||||
iconTrainDirection = directions ? true : false;
|
||||
}
|
||||
const [isWrapped, setIsWrapped] = useState(false);
|
||||
|
||||
const [typeString, fontAvailable, isOneMan] = getStringConfig(type, id);
|
||||
@@ -124,29 +148,11 @@ export const AllTrainDiagramView: FC = () => {
|
||||
onPress={() => openTrainInfo(id)}
|
||||
>
|
||||
<View style={{ marginHorizontal: 5, flexDirection: "row" }}>
|
||||
{todayOperation.length > 0
|
||||
? todayOperation.map((operation, index) => (
|
||||
<Image
|
||||
key={index}
|
||||
source={{ uri: operation.vehicle_img || train_info_img }}
|
||||
style={{
|
||||
width: 20,
|
||||
height: 22,
|
||||
marginHorizontal: 2,
|
||||
display: index == 0 ? "flex" : "none", //暫定対応:複数アイコンがある場合は最初のアイコンのみ表示
|
||||
}}
|
||||
/>
|
||||
))
|
||||
: train_info_img && (
|
||||
<Image
|
||||
source={{ uri: train_info_img }}
|
||||
style={{
|
||||
width: 20,
|
||||
height: 22,
|
||||
marginHorizontal: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<TrainIconStack
|
||||
entries={trainIconData}
|
||||
direction={iconTrainDirection}
|
||||
variant="list"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
@@ -173,22 +179,33 @@ export const AllTrainDiagramView: FC = () => {
|
||||
)}
|
||||
{isOneMan && <OneManText />}
|
||||
</View>
|
||||
{trainNameString && (
|
||||
<Text
|
||||
{(trainNameString || infogram) && (
|
||||
<View
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
color: fixed.textOnPrimary,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexShrink: 1,
|
||||
}}
|
||||
onTextLayout={(e) => {
|
||||
if (e.nativeEvent.lines.length > 1) {
|
||||
setIsWrapped(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{trainNameString}
|
||||
</Text>
|
||||
{trainNameString && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
color: fixed.textOnPrimary,
|
||||
flexShrink: 1,
|
||||
}}
|
||||
onTextLayout={(e) => {
|
||||
if (e.nativeEvent.lines.length > 1) {
|
||||
setIsWrapped(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{trainNameString}
|
||||
</Text>
|
||||
)}
|
||||
{infogram ? <InfogramText infogram={infogram} fontSize={18} /> : null}
|
||||
</View>
|
||||
)}
|
||||
<View style={{ flex: 1 }} />
|
||||
<Text style={{ fontSize: 20, fontWeight: "bold", color: fixed.textOnPrimary }}>
|
||||
|
||||
@@ -26,6 +26,8 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import dayjs from "dayjs";
|
||||
import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
|
||||
import { resolveTrainDataIcon } from "@/lib/trainDataIcon";
|
||||
import {
|
||||
startTrainFollowActivity,
|
||||
updateTrainFollowActivity,
|
||||
@@ -48,8 +50,9 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
setFixedPositionSize,
|
||||
} = useCurrentTrain();
|
||||
|
||||
const { mapSwitch } = useTrainMenu();
|
||||
const { mapSwitch, iconSetting } = useTrainMenu();
|
||||
const { allCustomTrainData, allTrainDiagram } = useAllTrainDiagram();
|
||||
const iconDisplayMode = normalizeIconDisplayMode(iconSetting);
|
||||
|
||||
const [liveNotifyId, setLiveNotifyId] = useState<string | null>(null);
|
||||
const liveNotifyIdRef = useRef<string | null>(null);
|
||||
@@ -59,11 +62,12 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
const [customData, setCustomData] = useState<CustomTrainData>(
|
||||
getCurrentTrainData(trainID, currentTrain, allCustomTrainData)
|
||||
);
|
||||
const customTrainIcon = resolveTrainDataIcon(customData, iconDisplayMode);
|
||||
useEffect(() => {
|
||||
setCustomData(
|
||||
getCurrentTrainData(trainID, currentTrain, allCustomTrainData)
|
||||
);
|
||||
}, [currentTrain, trainID]);
|
||||
}, [currentTrain, trainID, allCustomTrainData]);
|
||||
useEffect(() => {
|
||||
const stationData = getCurrentStationData(trainID);
|
||||
if (stationData) {
|
||||
@@ -555,7 +559,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: customData.train_info_img || "" }}
|
||||
source={{ uri: customTrainIcon }}
|
||||
width={fixedPositionSize === 226 ? 23 : 14}
|
||||
height={fixedPositionSize === 226 ? 26 : 17}
|
||||
style={{ margin: 5 }}
|
||||
|
||||
@@ -24,23 +24,49 @@ export const LayoutSettings = ({
|
||||
setTrainPosition,
|
||||
headerSize,
|
||||
setHeaderSize,
|
||||
allowHubIcon = false,
|
||||
}) => {
|
||||
const { goBack } = useNavigation() as any;
|
||||
const { colors, fixed } = useThemeColors();
|
||||
const visibleIconSetting =
|
||||
allowHubIcon || iconSetting !== "hub" ? iconSetting : "original";
|
||||
return (
|
||||
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
|
||||
<SheetHeaderItem title="レイアウト設定" LeftItem={{ title: "< 設定", onPress: goBack }} />
|
||||
<ScrollView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<SwitchArea
|
||||
str="列車アイコン表示"
|
||||
bool={iconSetting}
|
||||
setBool={setIconSetting}
|
||||
falseImage={require("../../assets/configuration/icon_default.jpg")}
|
||||
trueImage={require("../../assets/configuration/icon_original.jpg")}
|
||||
falseText={"本家\n(文字アイコン)"}
|
||||
trueText={"オリジナル\n(車種アイコン)"}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#00000010",
|
||||
borderRadius: 10,
|
||||
margin: 5,
|
||||
}}
|
||||
>
|
||||
<TripleSwitchArea
|
||||
str="列車アイコン表示"
|
||||
bool={visibleIconSetting}
|
||||
setBool={setIconSetting}
|
||||
firstItem={{
|
||||
firstImage: require("../../assets/configuration/icon_default.jpg"),
|
||||
firstText: "本家\n(文字アイコン)",
|
||||
firstValue: "default",
|
||||
}}
|
||||
secondItem={{
|
||||
secondImage: require("../../assets/configuration/icon_original.jpg"),
|
||||
secondText: "オリジナル\n(車種アイコン)",
|
||||
secondValue: "original",
|
||||
}}
|
||||
thirdItem={
|
||||
allowHubIcon
|
||||
? {
|
||||
thirdImage: require("../../assets/relationLogo/unyohub_logo.webp"),
|
||||
thirdText: "鉄道運用Hub\n(Hubアイコン)",
|
||||
thirdValue: "hub",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<SwitchArea
|
||||
str="列車表示"
|
||||
bool={uiSetting}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from "react";
|
||||
import { View, Text, ScrollView } from "react-native";
|
||||
import { Switch } from "@rneui/themed";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
|
||||
type OperationInfoSettingsProps = {
|
||||
operationLandscapeEnabled: boolean;
|
||||
setOperationLandscapeEnabled: (value: boolean) => void;
|
||||
operationCaptureEnabled: boolean;
|
||||
setOperationCaptureEnabled: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const OperationInfoSettings = ({
|
||||
operationLandscapeEnabled,
|
||||
setOperationLandscapeEnabled,
|
||||
operationCaptureEnabled,
|
||||
setOperationCaptureEnabled,
|
||||
}: OperationInfoSettingsProps) => {
|
||||
const { goBack } = useNavigation();
|
||||
const { colors, fixed } = useThemeColors();
|
||||
|
||||
return (
|
||||
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
|
||||
<SheetHeaderItem
|
||||
title="運行情報設定(β)"
|
||||
LeftItem={{ title: "< 設定", onPress: goBack }}
|
||||
/>
|
||||
<ScrollView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<SettingRow
|
||||
title="横倒し表示機能"
|
||||
description="端末を横向きにしたとき、運行情報ページを見やすく再構成した専用表示を有効にします。"
|
||||
value={operationLandscapeEnabled}
|
||||
onValueChange={setOperationLandscapeEnabled}
|
||||
/>
|
||||
<SettingRow
|
||||
title="スクリーンショット切り出し機能"
|
||||
description="運行情報ページ内に、項目単位・全体単位の切り出しボタンを表示します。"
|
||||
value={operationCaptureEnabled}
|
||||
onValueChange={setOperationCaptureEnabled}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
type SettingRowProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
value: boolean;
|
||||
onValueChange: (value: boolean) => void;
|
||||
};
|
||||
|
||||
const SettingRow = ({ title, description, value, onValueChange }: SettingRowProps) => {
|
||||
const { colors, fixed } = useThemeColors();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 14,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSecondary ?? "#ccc",
|
||||
backgroundColor: colors.surface,
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: 16, fontWeight: "600", color: colors.text }}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={{ marginTop: 4, fontSize: 13, lineHeight: 19, color: colors.textSecondary }}>
|
||||
{description}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
color={fixed.primary}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -17,7 +17,7 @@ import { useNotification } from "../../stateBox/useNotifications";
|
||||
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
|
||||
import { useThemeColors, type ColorThemePref } from "@/lib/theme/useThemeColors";
|
||||
|
||||
const versionCode = "7.0.6"; // Update this version code as needed
|
||||
const versionCode = "7.0.7"; // Update this version code as needed
|
||||
|
||||
export const SettingTopPage = ({
|
||||
testNFC,
|
||||
@@ -118,6 +118,12 @@ export const SettingTopPage = ({
|
||||
navigation.navigate("setting", { screen: "SoundSettings" })
|
||||
}
|
||||
/>
|
||||
<SettingList
|
||||
string="運行情報設定(β)"
|
||||
onPress={() =>
|
||||
navigation.navigate("setting", { screen: "OperationInfoSettings" })
|
||||
}
|
||||
/>
|
||||
|
||||
<SectionHeader title="通知・データ" />
|
||||
<SettingList
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { View, Text, ScrollView, Platform } from "react-native";
|
||||
import { Input, Switch } from "@rneui/themed";
|
||||
import { Switch } from "@rneui/themed";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
|
||||
import { AS } from "../../storageControl";
|
||||
@@ -9,10 +9,6 @@ import { useThemeColors } from "@/lib/theme";
|
||||
import { Asset } from "expo-asset";
|
||||
import { useAudioPlayer, setAudioModeAsync } from "expo-audio";
|
||||
import type { AudioSource } from "expo-audio";
|
||||
import {
|
||||
DEFAULT_VOICEPEAK_BASE_URL,
|
||||
normalizeVoicepeakBaseUrl,
|
||||
} from "@/lib/voicepeak";
|
||||
|
||||
const previewSound = require("../../assets/sound/rikka-test.mp3");
|
||||
|
||||
@@ -20,12 +16,6 @@ export const SoundSettings = () => {
|
||||
const { goBack } = useNavigation();
|
||||
const { colors, fixed } = useThemeColors();
|
||||
const [delayAnnouncement, setDelayAnnouncement] = useState(false);
|
||||
const [voicepeakEnabled, setVoicepeakEnabled] = useState(false);
|
||||
const [voicepeakBaseUrl, setVoicepeakBaseUrl] = useState(
|
||||
DEFAULT_VOICEPEAK_BASE_URL
|
||||
);
|
||||
const [voicepeakApiToken, setVoicepeakApiToken] = useState("");
|
||||
const [voicepeakSpeaker, setVoicepeakSpeaker] = useState("");
|
||||
|
||||
// expo-asset でローカルパスを取得し、expo-audio に渡す
|
||||
const [resolvedSource, setResolvedSource] = useState<AudioSource>(null);
|
||||
@@ -64,31 +54,11 @@ export const SoundSettings = () => {
|
||||
const previewPlayer = useAudioPlayer(resolvedSource);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
AS.getItem(STORAGE_KEYS.SOUND_DELAY_ANNOUNCEMENT).catch(() => "false"),
|
||||
AS.getItem(STORAGE_KEYS.VOICEPEAK_ENABLED).catch(() => "false"),
|
||||
AS.getItem(STORAGE_KEYS.VOICEPEAK_BASE_URL).catch(
|
||||
() => DEFAULT_VOICEPEAK_BASE_URL
|
||||
),
|
||||
AS.getItem(STORAGE_KEYS.VOICEPEAK_API_TOKEN).catch(() => ""),
|
||||
AS.getItem(STORAGE_KEYS.VOICEPEAK_SPEAKER).catch(() => ""),
|
||||
]).then(
|
||||
([delayValue, enabledValue, baseUrlValue, tokenValue, speakerValue]) => {
|
||||
setDelayAnnouncement(delayValue === true || delayValue === "true");
|
||||
setVoicepeakEnabled(enabledValue === true || enabledValue === "true");
|
||||
setVoicepeakBaseUrl(
|
||||
typeof baseUrlValue === "string" && baseUrlValue.trim()
|
||||
? normalizeVoicepeakBaseUrl(baseUrlValue)
|
||||
: DEFAULT_VOICEPEAK_BASE_URL
|
||||
);
|
||||
setVoicepeakApiToken(
|
||||
typeof tokenValue === "string" ? tokenValue : ""
|
||||
);
|
||||
setVoicepeakSpeaker(
|
||||
typeof speakerValue === "string" ? speakerValue : ""
|
||||
);
|
||||
}
|
||||
);
|
||||
AS.getItem(STORAGE_KEYS.SOUND_DELAY_ANNOUNCEMENT)
|
||||
.then((v) => setDelayAnnouncement(v === true || v === "true"))
|
||||
.catch(() => {
|
||||
// 未設定時はデフォルト値 false のまま
|
||||
});
|
||||
}, []);
|
||||
|
||||
const playPreview = useCallback(async () => {
|
||||
@@ -115,18 +85,6 @@ export const SoundSettings = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveVoicepeakValue = useCallback(
|
||||
(key: string, value: string) => {
|
||||
AS.setItem(key, value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleVoicepeakToggle = (value: boolean) => {
|
||||
setVoicepeakEnabled(value);
|
||||
AS.setItem(STORAGE_KEYS.VOICEPEAK_ENABLED, value.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
|
||||
<SheetHeaderItem
|
||||
@@ -154,95 +112,6 @@ export const SoundSettings = () => {
|
||||
color={fixed.primary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<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 分前から Voicepeak API の案内音声を再生します。
|
||||
</Text>
|
||||
|
||||
<Input
|
||||
label="Voicepeak API URL"
|
||||
value={voicepeakBaseUrl}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
placeholder={DEFAULT_VOICEPEAK_BASE_URL}
|
||||
onChangeText={setVoicepeakBaseUrl}
|
||||
onBlur={() => {
|
||||
const normalized = normalizeVoicepeakBaseUrl(voicepeakBaseUrl);
|
||||
setVoicepeakBaseUrl(normalized);
|
||||
saveVoicepeakValue(STORAGE_KEYS.VOICEPEAK_BASE_URL, normalized);
|
||||
}}
|
||||
containerStyle={{ paddingHorizontal: 0 }}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="API トークン"
|
||||
value={voicepeakApiToken}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry
|
||||
placeholder="Bearer トークンを入力"
|
||||
onChangeText={setVoicepeakApiToken}
|
||||
onBlur={() =>
|
||||
saveVoicepeakValue(
|
||||
STORAGE_KEYS.VOICEPEAK_API_TOKEN,
|
||||
voicepeakApiToken.trim()
|
||||
)
|
||||
}
|
||||
containerStyle={{ paddingHorizontal: 0 }}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="話者名(任意)"
|
||||
value={voicepeakSpeaker}
|
||||
autoCapitalize="words"
|
||||
autoCorrect={false}
|
||||
placeholder="例: Koharu Rikka"
|
||||
onChangeText={setVoicepeakSpeaker}
|
||||
onBlur={() =>
|
||||
saveVoicepeakValue(
|
||||
STORAGE_KEYS.VOICEPEAK_SPEAKER,
|
||||
voicepeakSpeaker.trim()
|
||||
)
|
||||
}
|
||||
containerStyle={{ paddingHorizontal: 0, marginBottom: 0 }}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -24,13 +24,20 @@ import { LauncherIconSettings } from "./LauncherIconSettings";
|
||||
import { DataSourceSettings } from "./DataSourceSettings";
|
||||
import { FelicaHistoryPage } from "./FelicaHistoryPage";
|
||||
import { SoundSettings } from "./SoundSettings";
|
||||
import { OperationInfoSettings } from "./OperationInfoSettings";
|
||||
import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||||
import {
|
||||
normalizeIconDisplayMode,
|
||||
type IconDisplayMode,
|
||||
} from "@/lib/iconDisplayMode";
|
||||
|
||||
const Stack = createStackNavigator();
|
||||
export default function Setting(props) {
|
||||
const {
|
||||
navigation: { navigate },
|
||||
} = props;
|
||||
const [iconSetting, setIconSetting] = useState(false);
|
||||
const { updatePermission } = useTrainMenu();
|
||||
const [iconSetting, setIconSetting] = useState<IconDisplayMode>("original");
|
||||
const [mapSwitch, setMapSwitch] = useState(false);
|
||||
const [stationMenu, setStationMenu] = useState(false);
|
||||
const [usePDFView, setUsePDFView] = useState(false);
|
||||
@@ -39,8 +46,12 @@ export default function Setting(props) {
|
||||
const [headerSize, setHeaderSize] = useState("default");
|
||||
const [startPage, setStartPage] = useState(false);
|
||||
const [uiSetting, setUiSetting] = useState("tokyo");
|
||||
const [operationLandscapeEnabled, setOperationLandscapeEnabled] = useState(false);
|
||||
const [operationCaptureEnabled, setOperationCaptureEnabled] = useState(false);
|
||||
useLayoutEffect(() => {
|
||||
AS.getItem(STORAGE_KEYS.ICON_SWITCH).then(setIconSetting);
|
||||
AS.getItem(STORAGE_KEYS.ICON_SWITCH).then((value) =>
|
||||
setIconSetting(normalizeIconDisplayMode(value)),
|
||||
);
|
||||
AS.getItem(STORAGE_KEYS.MAP_SWITCH).then(setMapSwitch);
|
||||
AS.getItem(STORAGE_KEYS.STATION_SWITCH).then(setStationMenu);
|
||||
AS.getItem(STORAGE_KEYS.USE_PDF_VIEW).then(setUsePDFView);
|
||||
@@ -49,6 +60,12 @@ export default function Setting(props) {
|
||||
AS.getItem(STORAGE_KEYS.HEADER_SIZE).then(setHeaderSize);
|
||||
AS.getItem(STORAGE_KEYS.START_PAGE).then(setStartPage);
|
||||
AS.getItem(STORAGE_KEYS.UI_SETTING).then(setUiSetting);
|
||||
AS.getItem(STORAGE_KEYS.OPERATION_INFO_LANDSCAPE_ENABLED).then((value) =>
|
||||
setOperationLandscapeEnabled(value === true || value === "true"),
|
||||
);
|
||||
AS.getItem(STORAGE_KEYS.OPERATION_INFO_CAPTURE_ENABLED).then((value) =>
|
||||
setOperationCaptureEnabled(value === true || value === "true"),
|
||||
);
|
||||
}, []);
|
||||
const testNFC = async () => {
|
||||
console.log("Testing NFC...");
|
||||
@@ -73,8 +90,10 @@ export default function Setting(props) {
|
||||
}
|
||||
};
|
||||
const updateAndReload = () => {
|
||||
const iconSettingToSave =
|
||||
updatePermission || iconSetting !== "hub" ? iconSetting : "original";
|
||||
Promise.all([
|
||||
AS.setItem(STORAGE_KEYS.ICON_SWITCH, iconSetting.toString()),
|
||||
AS.setItem(STORAGE_KEYS.ICON_SWITCH, iconSettingToSave.toString()),
|
||||
AS.setItem(STORAGE_KEYS.MAP_SWITCH, mapSwitch.toString()),
|
||||
AS.setItem(STORAGE_KEYS.STATION_SWITCH, stationMenu.toString()),
|
||||
AS.setItem(STORAGE_KEYS.USE_PDF_VIEW, usePDFView.toString()),
|
||||
@@ -83,6 +102,8 @@ export default function Setting(props) {
|
||||
AS.setItem(STORAGE_KEYS.HEADER_SIZE, headerSize),
|
||||
AS.setItem(STORAGE_KEYS.START_PAGE, startPage.toString()),
|
||||
AS.setItem(STORAGE_KEYS.UI_SETTING, uiSetting),
|
||||
AS.setItem(STORAGE_KEYS.OPERATION_INFO_LANDSCAPE_ENABLED, operationLandscapeEnabled.toString()),
|
||||
AS.setItem(STORAGE_KEYS.OPERATION_INFO_CAPTURE_ENABLED, operationCaptureEnabled.toString()),
|
||||
]).then(() => Updates.reloadAsync());
|
||||
};
|
||||
return (
|
||||
@@ -136,6 +157,7 @@ export default function Setting(props) {
|
||||
setUiSetting={setUiSetting}
|
||||
headerSize={headerSize}
|
||||
setHeaderSize={setHeaderSize}
|
||||
allowHubIcon={updatePermission}
|
||||
/>
|
||||
)}
|
||||
</Stack.Screen>
|
||||
@@ -205,6 +227,26 @@ export default function Setting(props) {
|
||||
}}
|
||||
component={SoundSettings}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="OperationInfoSettings"
|
||||
options={{
|
||||
gestureEnabled: true,
|
||||
...TransitionPresets.SlideFromRightIOS,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
{(props) => (
|
||||
<OperationInfoSettings
|
||||
{...props}
|
||||
operationLandscapeEnabled={operationLandscapeEnabled}
|
||||
setOperationLandscapeEnabled={setOperationLandscapeEnabled}
|
||||
operationCaptureEnabled={operationCaptureEnabled}
|
||||
setOperationCaptureEnabled={setOperationCaptureEnabled}
|
||||
/>
|
||||
)}
|
||||
</Stack.Screen>
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ export const TripleSwitchArea = ({
|
||||
str,
|
||||
bool,
|
||||
setBool,
|
||||
firstItem: { firstImage, firstText, firstValue },
|
||||
secondItem: { secondImage, secondText, secondValue },
|
||||
thirdItem: { thirdImage, thirdText, thirdValue },
|
||||
firstItem,
|
||||
secondItem,
|
||||
thirdItem,
|
||||
}) => {
|
||||
const { colors } = useThemeColors();
|
||||
const { firstImage, firstText, firstValue } = firstItem;
|
||||
const { secondImage, secondText, secondValue } = secondItem;
|
||||
return (
|
||||
<View style={{ flexDirection: "column", padding: 10 }}>
|
||||
<Text
|
||||
@@ -41,14 +43,16 @@ export const TripleSwitchArea = ({
|
||||
image={secondImage}
|
||||
subText={secondText}
|
||||
/>
|
||||
<SimpleSwitch
|
||||
bool={bool}
|
||||
setBool={setBool}
|
||||
color="red"
|
||||
value={thirdValue}
|
||||
image={thirdImage}
|
||||
subText={thirdText}
|
||||
/>
|
||||
{thirdItem ? (
|
||||
<SimpleSwitch
|
||||
bool={bool}
|
||||
setBool={setBool}
|
||||
color="red"
|
||||
value={thirdItem.thirdValue}
|
||||
image={thirdItem.thirdImage}
|
||||
subText={thirdItem.thirdText}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, FC, useCallback, useMemo, useRef } from "react";
|
||||
import { View, useWindowDimensions, Text, Platform } from "react-native";
|
||||
import React, { useState, useEffect, FC } from "react";
|
||||
import { View, useWindowDimensions, Text } from "react-native";
|
||||
import { objectIsEmpty } from "@/lib/objectIsEmpty";
|
||||
import { useCurrentTrain } from "@/stateBox/useCurrentTrain";
|
||||
import { useAreaInfo } from "@/stateBox/useAreaInfo";
|
||||
@@ -13,20 +13,7 @@ import { getTime, trainTimeFiltering } from "@/lib/trainTimeFiltering";
|
||||
import { eachTrainDiagramType, StationProps } from "@/lib/CommonTypes";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { getCurrentTrainData } from "@/lib/getCurrentTrainData";
|
||||
import { useAudioPlayer, setAudioModeAsync } from "expo-audio";
|
||||
import { useInterval } from "@/lib/useInterval";
|
||||
import {
|
||||
buildVoicepeakAnnouncementKey,
|
||||
buildVoicepeakAnnouncementText,
|
||||
getVoicepeakAnnouncementStage,
|
||||
hasVoicepeakConfiguration,
|
||||
loadVoicepeakSettings,
|
||||
requestVoicepeakSpeech,
|
||||
type VoicepeakSettings,
|
||||
} from "@/lib/voicepeak";
|
||||
import { EMPTY_NATIVE_VOICEPEAK_HTML } from "@/lib/voicepeakAudioSource";
|
||||
import { WebView } from "react-native-webview";
|
||||
import { stackAwareNavigate } from "@/lib/rootNavigation";
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -63,7 +50,7 @@ type props = {
|
||||
export const LED_vision: FC<props> = (props) => {
|
||||
const { station } = props;
|
||||
|
||||
const { navigate, addListener } = useNavigation();
|
||||
const { navigate } = useNavigation();
|
||||
const { currentTrain } = useCurrentTrain();
|
||||
const [stationDiagram, setStationDiagram] = useState<{
|
||||
[key: string]: string;
|
||||
@@ -73,29 +60,8 @@ export const LED_vision: FC<props> = (props) => {
|
||||
const [trainDescriptionSwitch, setTrainDescriptionSwitch] = useState(false);
|
||||
const [isInfoArea, setIsInfoArea] = useState(false);
|
||||
const { areaInfo, areaStationID } = useAreaInfo();
|
||||
const { allTrainDiagram, allCustomTrainData } = useAllTrainDiagram();
|
||||
const { allTrainDiagram } = useAllTrainDiagram();
|
||||
const { fixed } = useThemeColors();
|
||||
const [voicepeakSettings, setVoicepeakSettings] =
|
||||
useState<VoicepeakSettings | null>(null);
|
||||
const announcementPlayer = useAudioPlayer(null);
|
||||
const announcedKeysRef = useRef<Set<string>>(new Set());
|
||||
const pendingAnnouncementKeyRef = useRef<string | null>(null);
|
||||
const currentRequestRef = useRef<AbortController | null>(null);
|
||||
const cleanupAudioRef = useRef<(() => void) | undefined>(undefined);
|
||||
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(() => {
|
||||
AS.getItem("LEDSettings/trainIDSwitch").then((data) => {
|
||||
@@ -107,17 +73,7 @@ export const LED_vision: FC<props> = (props) => {
|
||||
AS.getItem("LEDSettings/finalSwitch").then((data) => {
|
||||
setFinalSwitch(data === "true");
|
||||
});
|
||||
|
||||
refreshVoicepeakSettings();
|
||||
|
||||
const unsubscribe = addListener("focus", refreshVoicepeakSettings);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
currentRequestRef.current?.abort();
|
||||
cleanupAudioRef.current?.();
|
||||
};
|
||||
}, [addListener, refreshVoicepeakSettings]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 現在の駅に停車するダイヤを作成する副作用[列車ダイヤと現在駅情報]
|
||||
@@ -162,139 +118,6 @@ export const LED_vision: FC<props> = (props) => {
|
||||
setSelectedTrain(data);
|
||||
}, [trainTimeAndNumber, currentTrain, finalSwitch]);
|
||||
|
||||
const voicepeakCandidates = useMemo(() => {
|
||||
if (!currentTrain?.length || !allCustomTrainData) return [];
|
||||
|
||||
return selectedTrain
|
||||
.map((train) => {
|
||||
const currentTrainData = getCurrentTrainData(
|
||||
train.train,
|
||||
currentTrain,
|
||||
allCustomTrainData
|
||||
);
|
||||
const currentTrainStatus = currentTrain.find(
|
||||
(currentTrainItem) => currentTrainItem.num === train.train
|
||||
);
|
||||
const stage = currentTrainData
|
||||
? getVoicepeakAnnouncementStage({
|
||||
station: station[0],
|
||||
train,
|
||||
currentTrainData,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!currentTrainData || !stage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
key: buildVoicepeakAnnouncementKey(station[0], train, stage),
|
||||
text: buildVoicepeakAnnouncementText({
|
||||
station: station[0],
|
||||
train,
|
||||
currentTrainData,
|
||||
stage,
|
||||
delayMinutes:
|
||||
typeof currentTrainStatus?.delay === "number"
|
||||
? currentTrainStatus.delay
|
||||
: 0,
|
||||
}),
|
||||
departureTime: train.time,
|
||||
priority: stage === "departure" ? 0 : 1,
|
||||
};
|
||||
})
|
||||
.filter((candidate): candidate is NonNullable<typeof candidate> => !!candidate)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.priority - b.priority ||
|
||||
a.departureTime.localeCompare(b.departureTime)
|
||||
);
|
||||
}, [allCustomTrainData, currentTrain, selectedTrain, station]);
|
||||
|
||||
const playVoicepeakAnnouncement = useCallback(
|
||||
async (candidate: { key: string; text: string }) => {
|
||||
if (!voicepeakSettings || !hasVoicepeakConfiguration(voicepeakSettings)) {
|
||||
return;
|
||||
}
|
||||
if (pendingAnnouncementKeyRef.current === candidate.key) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingAnnouncementKeyRef.current = candidate.key;
|
||||
currentRequestRef.current?.abort();
|
||||
|
||||
const controller = new AbortController();
|
||||
currentRequestRef.current = controller;
|
||||
|
||||
try {
|
||||
const audio = await requestVoicepeakSpeech({
|
||||
text: candidate.text,
|
||||
settings: voicepeakSettings,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
cleanupAudioRef.current?.();
|
||||
cleanupAudioRef.current =
|
||||
audio.kind === "expo-audio" ? audio.cleanup : undefined;
|
||||
|
||||
await setAudioModeAsync({
|
||||
playsInSilentMode: true,
|
||||
shouldPlayInBackground: false,
|
||||
interruptionMode: "duckOthers",
|
||||
});
|
||||
|
||||
if (audio.kind === "native-webview") {
|
||||
setNativeVoicepeakHtml(audio.html);
|
||||
setNativeVoicepeakPlaybackKey((current) => current + 1);
|
||||
} else {
|
||||
if (announcementPlayer.playing) {
|
||||
announcementPlayer.pause();
|
||||
}
|
||||
|
||||
announcementPlayer.replace(audio.source);
|
||||
announcementPlayer.volume = 1;
|
||||
await announcementPlayer.seekTo(0);
|
||||
announcementPlayer.play();
|
||||
}
|
||||
announcedKeysRef.current.add(candidate.key);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
console.warn("Failed to play Voicepeak announcement", error);
|
||||
}
|
||||
} finally {
|
||||
if (currentRequestRef.current === controller) {
|
||||
currentRequestRef.current = null;
|
||||
}
|
||||
if (pendingAnnouncementKeyRef.current === candidate.key) {
|
||||
pendingAnnouncementKeyRef.current = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
[announcementPlayer, voicepeakSettings]
|
||||
);
|
||||
|
||||
const checkVoicepeakAnnouncement = useCallback(() => {
|
||||
if (!voicepeakSettings || !hasVoicepeakConfiguration(voicepeakSettings)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = voicepeakCandidates.find(
|
||||
(item) => !announcedKeysRef.current.has(item.key)
|
||||
);
|
||||
|
||||
if (!candidate) return;
|
||||
|
||||
void playVoicepeakAnnouncement(candidate);
|
||||
}, [playVoicepeakAnnouncement, voicepeakCandidates, voicepeakSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
checkVoicepeakAnnouncement();
|
||||
}, [checkVoicepeakAnnouncement]);
|
||||
|
||||
useInterval(() => {
|
||||
checkVoicepeakAnnouncement();
|
||||
}, 15000);
|
||||
|
||||
const { width } = useWindowDimensions();
|
||||
const adjustedWidth = width * 0.98;
|
||||
return (
|
||||
@@ -307,23 +130,6 @@ export const LED_vision: FC<props> = (props) => {
|
||||
marginHorizontal: width * 0.01,
|
||||
}}
|
||||
>
|
||||
{Platform.OS !== "web" && (
|
||||
<WebView
|
||||
key={nativeVoicepeakPlaybackKey}
|
||||
source={{ html: nativeVoicepeakHtml }}
|
||||
originWhitelist={["*"]}
|
||||
javaScriptEnabled
|
||||
scrollEnabled={false}
|
||||
mediaPlaybackRequiresUserAction={false}
|
||||
allowsInlineMediaPlayback
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 1,
|
||||
height: 1,
|
||||
opacity: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Header station={station[0]} />
|
||||
|
||||
<View
|
||||
@@ -358,7 +164,7 @@ export const LED_vision: FC<props> = (props) => {
|
||||
<AreaDescription
|
||||
numberOfLines={1}
|
||||
areaInfo={areaInfo}
|
||||
onClick={() => alert(areaInfo)}
|
||||
onClick={() => stackAwareNavigate("information")}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
+6
-12
@@ -109,21 +109,15 @@ export const STORAGE_KEYS = {
|
||||
/** 駅固定モード遅延速報案内機能(サウンド) */
|
||||
SOUND_DELAY_ANNOUNCEMENT: 'soundDelayAnnouncement',
|
||||
|
||||
/** Voicepeak 発車案内機能の有効化スイッチ */
|
||||
VOICEPEAK_ENABLED: 'voicepeakEnabled',
|
||||
|
||||
/** Voicepeak API ベースURL */
|
||||
VOICEPEAK_BASE_URL: 'voicepeakBaseUrl',
|
||||
|
||||
/** Voicepeak API トークン */
|
||||
VOICEPEAK_API_TOKEN: 'voicepeakApiToken',
|
||||
|
||||
/** Voicepeak 話者名 */
|
||||
VOICEPEAK_SPEAKER: 'voicepeakSpeaker',
|
||||
|
||||
/** カラーテーマ設定 ("light" | "system" | "dark") */
|
||||
COLOR_THEME: 'colorTheme',
|
||||
|
||||
/** 運行情報横倒し機能の有効化スイッチ(β) */
|
||||
OPERATION_INFO_LANDSCAPE_ENABLED: 'operationInfoLandscapeEnabled',
|
||||
|
||||
/** 運行情報スクリーンショット切り出し機能の有効化スイッチ(β) */
|
||||
OPERATION_INFO_CAPTURE_ENABLED: 'operationInfoCaptureEnabled',
|
||||
|
||||
/** モックAPI検証機能の有効化スイッチ(admin専用) */
|
||||
MOCK_API_FEATURE_ENABLED: 'mockApiFeatureEnabled',
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,7 @@ export type CustomTrainData = {
|
||||
type: trainTypeID;
|
||||
train_name: string;
|
||||
train_info_img: string;
|
||||
train_info_img_hub?: string | null;
|
||||
train_info_url: string;
|
||||
infogram: string;
|
||||
via_data: string;
|
||||
@@ -96,6 +97,8 @@ export type OperationLogs = {
|
||||
unit_ids?: string[];
|
||||
vehicle_img: string;
|
||||
vehicle_img_right: string;
|
||||
vehicle_img_hub?: string | null;
|
||||
vehicle_img_right_hub?: string | null;
|
||||
vehicle_info_url: string;
|
||||
related_train_ids?: string[];
|
||||
state: number | null;
|
||||
|
||||
@@ -24,7 +24,7 @@ export const getStringConfig: types = (type, id) => {
|
||||
case "SPCL_EXP":
|
||||
return ["臨時特急", true, false];
|
||||
case "Party":
|
||||
return ["団体臨時", true, false];
|
||||
return ["団体", true, false];
|
||||
case "Freight":
|
||||
return ["貨物", false, false];
|
||||
case "Forwarding":
|
||||
|
||||
+3
-3
@@ -22,7 +22,7 @@ type trainTypeString =
|
||||
| "普通列車(ワンマン)"
|
||||
| "臨時快速"
|
||||
| "臨時特急"
|
||||
| "団体臨時"
|
||||
| "団体"
|
||||
| "貨物"
|
||||
| "回送"
|
||||
| "単機回送"
|
||||
@@ -129,8 +129,8 @@ export const getTrainType: getTrainType = ({ type, id, whiteMode }) => {
|
||||
case "Party":
|
||||
return {
|
||||
color: "#ff7300ff",
|
||||
name: "団体臨時",
|
||||
shortName: "団体臨時",
|
||||
name: "団体",
|
||||
shortName: "団体",
|
||||
fontAvailable: true,
|
||||
isOneMan: false,
|
||||
data: "normal",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export type IconDisplayMode = "default" | "original" | "hub";
|
||||
|
||||
export const normalizeIconDisplayMode = (
|
||||
value: unknown,
|
||||
): IconDisplayMode => {
|
||||
if (value === "hub") return "hub";
|
||||
if (value === "default" || value === "false" || value === false) {
|
||||
return "default";
|
||||
}
|
||||
return "original";
|
||||
};
|
||||
|
||||
export const isHubIconDisplayMode = (value: unknown): boolean =>
|
||||
normalizeIconDisplayMode(value) === "hub";
|
||||
|
||||
export const usesCustomTrainIcons = (value: unknown): boolean =>
|
||||
normalizeIconDisplayMode(value) !== "default";
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { OperationLogs } from "@/lib/CommonTypes";
|
||||
import {
|
||||
normalizeIconDisplayMode,
|
||||
type IconDisplayMode,
|
||||
} from "@/lib/iconDisplayMode";
|
||||
|
||||
type OperationIconPair = {
|
||||
forward: string;
|
||||
rear: string;
|
||||
};
|
||||
|
||||
export const resolveOperationIconPair = (
|
||||
operation: Pick<
|
||||
OperationLogs,
|
||||
| "vehicle_img"
|
||||
| "vehicle_img_right"
|
||||
| "vehicle_img_hub"
|
||||
| "vehicle_img_right_hub"
|
||||
>,
|
||||
iconDisplayMode: IconDisplayMode,
|
||||
fallback = "",
|
||||
): OperationIconPair => {
|
||||
if (iconDisplayMode === "hub") {
|
||||
return {
|
||||
forward: operation.vehicle_img_hub || operation.vehicle_img || fallback,
|
||||
rear:
|
||||
operation.vehicle_img_right_hub ||
|
||||
operation.vehicle_img_hub ||
|
||||
operation.vehicle_img_right ||
|
||||
operation.vehicle_img ||
|
||||
fallback,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
forward: operation.vehicle_img || fallback,
|
||||
rear: operation.vehicle_img_right || operation.vehicle_img || fallback,
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveDirectionalOperationIcon = (
|
||||
operation: Pick<
|
||||
OperationLogs,
|
||||
| "vehicle_img"
|
||||
| "vehicle_img_right"
|
||||
| "vehicle_img_hub"
|
||||
| "vehicle_img_right_hub"
|
||||
>,
|
||||
iconDisplayModeValue: unknown,
|
||||
direction: boolean,
|
||||
fallback = "",
|
||||
): string => {
|
||||
const iconDisplayMode = normalizeIconDisplayMode(iconDisplayModeValue);
|
||||
const { forward, rear } = resolveOperationIconPair(
|
||||
operation,
|
||||
iconDisplayMode,
|
||||
fallback,
|
||||
);
|
||||
return direction ? forward || rear : rear || forward;
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { CustomTrainData } from "@/lib/CommonTypes";
|
||||
import {
|
||||
normalizeIconDisplayMode,
|
||||
type IconDisplayMode,
|
||||
} from "@/lib/iconDisplayMode";
|
||||
|
||||
export const resolveTrainDataIcon = (
|
||||
trainData: Pick<CustomTrainData, "train_info_img" | "train_info_img_hub">,
|
||||
iconDisplayMode: IconDisplayMode,
|
||||
fallback = "",
|
||||
): string => {
|
||||
if (iconDisplayMode === "hub") {
|
||||
return trainData.train_info_img_hub || trainData.train_info_img || fallback;
|
||||
}
|
||||
|
||||
return trainData.train_info_img || fallback;
|
||||
};
|
||||
|
||||
export const resolveTrainDataIconFromValue = (
|
||||
trainData: Pick<CustomTrainData, "train_info_img" | "train_info_img_hub">,
|
||||
iconDisplayModeValue: unknown,
|
||||
fallback = "",
|
||||
): string =>
|
||||
resolveTrainDataIcon(
|
||||
trainData,
|
||||
normalizeIconDisplayMode(iconDisplayModeValue),
|
||||
fallback,
|
||||
);
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { CustomTrainData, OperationLogs } from "@/lib/CommonTypes";
|
||||
import type { IconDisplayMode } from "@/lib/iconDisplayMode";
|
||||
import { resolveOperationIconPair } from "@/lib/operationLogIcon";
|
||||
import { resolveTrainDataIcon } from "@/lib/trainDataIcon";
|
||||
|
||||
export type TrainIconEntry = {
|
||||
vehicle_info_img: string;
|
||||
vehicle_info_right_img: string;
|
||||
vehicle_info_url: string;
|
||||
};
|
||||
|
||||
type TrainDataForIcon = Pick<
|
||||
CustomTrainData,
|
||||
"train_info_img" | "train_info_img_hub" | "vehicle_info_url"
|
||||
>;
|
||||
|
||||
const extractOrderNumber = (trainId: string): number => {
|
||||
const parts = trainId.split(",");
|
||||
if (parts.length <= 1) return Infinity;
|
||||
|
||||
const num = parseInt(parts[1].trim(), 10);
|
||||
return isNaN(num) ? Infinity : num;
|
||||
};
|
||||
|
||||
const findMatchingTrainId = (
|
||||
operation: OperationLogs,
|
||||
trainNum: string,
|
||||
): string | null => {
|
||||
const allTrainIds = [
|
||||
...(operation.train_ids || []),
|
||||
...(operation.related_train_ids || []),
|
||||
];
|
||||
|
||||
for (const trainId of allTrainIds) {
|
||||
const prefix = trainId.split(",")[0];
|
||||
if (prefix === trainNum) return trainId;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const sortOperationsForTrainIcon = (
|
||||
operations: OperationLogs[],
|
||||
trainNum: string,
|
||||
): OperationLogs[] =>
|
||||
[...operations].sort((a, b) => {
|
||||
const aTrainId = findMatchingTrainId(a, trainNum);
|
||||
const bTrainId = findMatchingTrainId(b, trainNum);
|
||||
|
||||
if (!aTrainId || !bTrainId) {
|
||||
return aTrainId ? -1 : bTrainId ? 1 : 0;
|
||||
}
|
||||
|
||||
return extractOrderNumber(aTrainId) - extractOrderNumber(bTrainId);
|
||||
});
|
||||
|
||||
export const resolveTrainIconEntries = ({
|
||||
trainNum,
|
||||
customTrainData,
|
||||
todayOperation,
|
||||
iconDisplayMode,
|
||||
}: {
|
||||
trainNum: string;
|
||||
customTrainData: TrainDataForIcon;
|
||||
todayOperation: OperationLogs[];
|
||||
iconDisplayMode: IconDisplayMode;
|
||||
}): TrainIconEntry[] => {
|
||||
const fallbackIcon = resolveTrainDataIcon(customTrainData, iconDisplayMode);
|
||||
|
||||
if (todayOperation.length > 0) {
|
||||
return sortOperationsForTrainIcon(todayOperation, trainNum)
|
||||
.map((operation) => {
|
||||
const { forward, rear } = resolveOperationIconPair(
|
||||
operation,
|
||||
iconDisplayMode,
|
||||
fallbackIcon,
|
||||
);
|
||||
|
||||
return {
|
||||
vehicle_info_img: forward,
|
||||
vehicle_info_right_img: rear,
|
||||
vehicle_info_url: operation.vehicle_info_url,
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.vehicle_info_img || entry.vehicle_info_right_img);
|
||||
}
|
||||
|
||||
if (!fallbackIcon) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
vehicle_info_img: fallbackIcon,
|
||||
vehicle_info_right_img: fallbackIcon,
|
||||
vehicle_info_url: customTrainData.vehicle_info_url || "",
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -1,272 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import { STORAGE_KEYS } from "@/constants";
|
||||
import type { CustomTrainData, StationProps, eachTrainDiagramType } from "@/lib/CommonTypes";
|
||||
import { getTrainType } from "@/lib/getTrainType";
|
||||
import { AS } from "@/storageControl";
|
||||
import {
|
||||
createVoicepeakAudioSource,
|
||||
type PreparedVoicepeakAudio,
|
||||
} from "@/lib/voicepeakAudioSource";
|
||||
|
||||
export const DEFAULT_VOICEPEAK_BASE_URL = "https://voicepeak-api.haruk.in";
|
||||
|
||||
export type VoicepeakSettings = {
|
||||
enabled: boolean;
|
||||
baseUrl: string;
|
||||
apiToken: string;
|
||||
speaker: string;
|
||||
};
|
||||
|
||||
export type VoicepeakAnnouncementStage = "advance" | "departure";
|
||||
|
||||
type VoicepeakSpeechRequest = {
|
||||
text: string;
|
||||
settings: VoicepeakSettings;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
const readStoredString = async (key: string, fallback = "") => {
|
||||
try {
|
||||
const value = await AS.getItem(key);
|
||||
return typeof value === "string" ? value : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeVoicepeakBaseUrl = (value?: string) =>
|
||||
(value?.trim() || DEFAULT_VOICEPEAK_BASE_URL).replace(/\/+$/, "");
|
||||
|
||||
export const loadVoicepeakSettings = async (): Promise<VoicepeakSettings> => {
|
||||
const [enabled, baseUrl, apiToken, speaker] = await Promise.all([
|
||||
readStoredString(STORAGE_KEYS.VOICEPEAK_ENABLED, "false"),
|
||||
readStoredString(
|
||||
STORAGE_KEYS.VOICEPEAK_BASE_URL,
|
||||
DEFAULT_VOICEPEAK_BASE_URL
|
||||
),
|
||||
readStoredString(STORAGE_KEYS.VOICEPEAK_API_TOKEN),
|
||||
readStoredString(STORAGE_KEYS.VOICEPEAK_SPEAKER),
|
||||
]);
|
||||
|
||||
return {
|
||||
enabled: enabled === "true",
|
||||
baseUrl: normalizeVoicepeakBaseUrl(baseUrl),
|
||||
apiToken: apiToken.trim(),
|
||||
speaker: speaker.trim(),
|
||||
};
|
||||
};
|
||||
|
||||
export const hasVoicepeakConfiguration = (settings: VoicepeakSettings) =>
|
||||
settings.enabled &&
|
||||
normalizeVoicepeakBaseUrl(settings.baseUrl).length > 0 &&
|
||||
settings.apiToken.length > 0;
|
||||
|
||||
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) => {
|
||||
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)
|
||||
.set("second", 0)
|
||||
.set("millisecond", 0);
|
||||
|
||||
return {
|
||||
now,
|
||||
departureTime,
|
||||
diffSeconds: departureTime.diff(now, "second"),
|
||||
};
|
||||
};
|
||||
|
||||
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 buildTrainDestination = (station: StationProps, train: eachTrainDiagramType) =>
|
||||
train.lastStation === "当駅止"
|
||||
? `${station.Station_JP}止まり`
|
||||
: `${train.lastStation}行き`;
|
||||
|
||||
const buildTrainLabel = (
|
||||
trainTypeName: string,
|
||||
trainName: string,
|
||||
trainNumberSuffix: string
|
||||
) => [trainTypeName, trainName, trainNumberSuffix].filter(Boolean).join(" ");
|
||||
|
||||
export const getVoicepeakAnnouncementStage = ({
|
||||
station,
|
||||
train,
|
||||
currentTrainData,
|
||||
}: {
|
||||
station: StationProps;
|
||||
train: eachTrainDiagramType;
|
||||
currentTrainData: CustomTrainData;
|
||||
}): VoicepeakAnnouncementStage | null => {
|
||||
if (train.isThrough) return null;
|
||||
if (train.lastStation === station.Station_JP) return null;
|
||||
|
||||
const trainType = getTrainType({ type: currentTrainData.type, id: train.train });
|
||||
if (trainType.data === "notService") return null;
|
||||
|
||||
const timing = getDepartureTiming(train.time);
|
||||
if (!timing) return null;
|
||||
|
||||
if (timing.diffSeconds <= 0 && timing.diffSeconds > -60) {
|
||||
return "departure";
|
||||
}
|
||||
|
||||
if (timing.diffSeconds > 0 && timing.diffSeconds < 120) {
|
||||
return "advance";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const buildVoicepeakAnnouncementText = ({
|
||||
station,
|
||||
train,
|
||||
currentTrainData,
|
||||
stage,
|
||||
delayMinutes,
|
||||
}: {
|
||||
station: StationProps;
|
||||
train: eachTrainDiagramType;
|
||||
currentTrainData: CustomTrainData;
|
||||
stage: VoicepeakAnnouncementStage;
|
||||
delayMinutes?: number;
|
||||
}) => {
|
||||
const trainType = getTrainType({ type: currentTrainData.type, id: train.train });
|
||||
const trainNumberSuffix = getTrainNumberSuffix(
|
||||
train.train,
|
||||
currentTrainData.train_num_distance
|
||||
);
|
||||
const destination = buildTrainDestination(station, train);
|
||||
const trainLabel = buildTrainLabel(
|
||||
trainType.name,
|
||||
currentTrainData.train_name,
|
||||
trainNumberSuffix
|
||||
);
|
||||
|
||||
if (stage === "advance") {
|
||||
const trainInfoText = currentTrainData.train_info?.trim();
|
||||
const [hourText, minuteText] = train.time.split(":");
|
||||
const parsedDelay =
|
||||
typeof delayMinutes === "number" && Number.isFinite(delayMinutes) && delayMinutes > 0
|
||||
? `${delayMinutes}分遅れで`
|
||||
: "定刻で";
|
||||
const prefix = [
|
||||
"次の列車は",
|
||||
`${Number.parseInt(hourText || "0", 10)}時`,
|
||||
`${Number.parseInt(minuteText || "0", 10)}分発`,
|
||||
trainLabel,
|
||||
`${destination}です。`,
|
||||
"この列車は",
|
||||
"現在",
|
||||
parsedDelay,
|
||||
"運転しております。",
|
||||
].join(" ");
|
||||
|
||||
if (trainInfoText) {
|
||||
return `${prefix} ${trainInfoText}`;
|
||||
}
|
||||
|
||||
return `${prefix} まもなく発車します。`;
|
||||
}
|
||||
|
||||
const platformText = train.platformNum?.trim()
|
||||
? `${train.platformNum.trim()}番線より`
|
||||
: "";
|
||||
const pieces = [
|
||||
"間もなく",
|
||||
platformText,
|
||||
trainLabel,
|
||||
destination,
|
||||
"が",
|
||||
"発車します。",
|
||||
"ご注意ください。",
|
||||
].filter(Boolean);
|
||||
|
||||
return pieces.join(" ");
|
||||
};
|
||||
|
||||
export const requestVoicepeakSpeech = async ({
|
||||
text,
|
||||
settings,
|
||||
signal,
|
||||
}: VoicepeakSpeechRequest): Promise<PreparedVoicepeakAudio> => {
|
||||
const payload: {
|
||||
text: string;
|
||||
format: "mp3";
|
||||
speaker?: string;
|
||||
} = {
|
||||
text,
|
||||
format: "mp3",
|
||||
};
|
||||
|
||||
if (settings.speaker) {
|
||||
payload.speaker = settings.speaker;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${normalizeVoicepeakBaseUrl(settings.baseUrl)}/v1/speech`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${settings.apiToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
errorText || `Voicepeak API request failed with status ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
return createVoicepeakAudioSource(bytes, "mp3");
|
||||
};
|
||||
@@ -1,115 +0,0 @@
|
||||
import type { AudioSource } from "expo-audio";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export type PreparedVoicepeakAudio =
|
||||
| {
|
||||
kind: "expo-audio";
|
||||
source: AudioSource;
|
||||
cleanup?: () => void;
|
||||
}
|
||||
| {
|
||||
kind: "native-webview";
|
||||
html: string;
|
||||
};
|
||||
|
||||
const BASE64_CHARS =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
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);
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "native-webview",
|
||||
html: buildNativeVoicepeakHtml(bytes, extension),
|
||||
};
|
||||
};
|
||||
@@ -3,6 +3,11 @@ import { TRAIN_TYPE_CONFIG } from './webview/trainTypeConfig';
|
||||
import { TRAIN_ICON_MAP, TRAIN_ICON_REGEX } from './webview/trainIconMap';
|
||||
import { STATION_DATA } from './webview/stationData';
|
||||
import { generateXhrInterceptorJs, MockApiConfig } from './mockApi/webviewXhrInterceptor';
|
||||
import {
|
||||
isHubIconDisplayMode,
|
||||
normalizeIconDisplayMode,
|
||||
usesCustomTrainIcons,
|
||||
} from './iconDisplayMode';
|
||||
import dosan from '@/assets/originData/dosan';
|
||||
import dosan2 from '@/assets/originData/dosan2';
|
||||
import koutoku from '@/assets/originData/koutoku';
|
||||
@@ -16,7 +21,7 @@ import yosan from '@/assets/originData/yosan';
|
||||
export interface InjectJavascriptOptions {
|
||||
/** 地図スイッチ ("true" | "false") */
|
||||
mapSwitch: string;
|
||||
/** 列車アイコン表示スイッチ ("true" | "false") */
|
||||
/** 列車アイコン表示モード ("default" | "original" | "hub") */
|
||||
iconSetting: string;
|
||||
/** 駅メニュースイッチ ("true" | "false") */
|
||||
stationMenu: string;
|
||||
@@ -63,6 +68,9 @@ export const injectJavascriptData = ({
|
||||
diagramTodayUrl,
|
||||
mockApiConfig,
|
||||
}: InjectJavascriptOptions): string => {
|
||||
const iconDisplayMode = normalizeIconDisplayMode(iconSetting);
|
||||
const preferHubTrainIcon = isHubIconDisplayMode(iconDisplayMode);
|
||||
const useVehicleIcons = usesCustomTrainIcons(iconDisplayMode);
|
||||
const MARINE_STATION_SEQUENCE = [
|
||||
"岡山", "大元", "備前西市", "妹尾", "早島", "茶屋町", "植松", "木見", "上の町",
|
||||
"児島", "坂出", "鴨川", "国分", "端岡", "鬼無", "高松",
|
||||
@@ -626,17 +634,22 @@ export const injectJavascriptData = ({
|
||||
`;
|
||||
// 左か右かを判定してアイコンを設置する
|
||||
const trainIcon = `
|
||||
const _preferHubTrainIcon = ${preferHubTrainIcon};
|
||||
const setStationIcon = (setIconElem,img,hasProblem,backCount = 100) =>{
|
||||
const position = setIconElem.getAttribute("style").includes("left");
|
||||
const isHubDataset = _preferHubTrainIcon && typeof img === "string" && img.includes("unyohub-icon-dataset/");
|
||||
let marginData = ${uiSetting === "tokyo" ? `"5px"`: `"2px"`};
|
||||
let backgroundColor = "transparent";
|
||||
let heightData = "22px";
|
||||
let widthData = isHubDataset ? "18px" : "auto";
|
||||
let transformData = isHubDataset ? "scale(1.15)" : "none";
|
||||
if(backCount == 0){
|
||||
marginData = position ? ${uiSetting === "tokyo" ? `"0px 0px -10px 0px" : "-10px 0px 0px 0px"`: `"0px 2px 0px 0px" : "0px 2px 0px 0px"`};
|
||||
heightData = "16px";
|
||||
widthData = isHubDataset ? "13px" : "auto";
|
||||
}
|
||||
|
||||
setIconElem.insertAdjacentHTML('beforebegin', "<img src="+img+" style='float:"+(position ? 'left' : 'right')+";height:"+heightData+";margin: "+marginData+";background-color: "+backgroundColor+";'>");
|
||||
setIconElem.insertAdjacentHTML('beforebegin', "<img src="+img+" style='float:"+(position ? 'left' : 'right')+";height:"+heightData+";width:"+widthData+";object-fit:contain;transform:"+transformData+";transform-origin:center center;margin: "+marginData+";background-color: "+backgroundColor+";'>");
|
||||
if (backCount == 0 || backCount == 100) setIconElem.remove();
|
||||
}
|
||||
|
||||
@@ -650,6 +663,16 @@ export const injectJavascriptData = ({
|
||||
if (new RegExp(pattern).test(列番データ)) return url;
|
||||
}
|
||||
};
|
||||
|
||||
const pickOperationTrainIcon = (operation, direction) => {
|
||||
const forward = _preferHubTrainIcon
|
||||
? (operation.vehicle_img_hub || operation.vehicle_img)
|
||||
: operation.vehicle_img;
|
||||
const rear = _preferHubTrainIcon
|
||||
? (operation.vehicle_img_right_hub || operation.vehicle_img_hub || operation.vehicle_img_right || operation.vehicle_img)
|
||||
: (operation.vehicle_img_right || operation.vehicle_img);
|
||||
return direction ? (forward || rear) : (rear || forward);
|
||||
};
|
||||
`;
|
||||
|
||||
const normal_train_name = `
|
||||
@@ -1159,7 +1182,7 @@ const setStrings = () =>{
|
||||
var TrainType = undefined;
|
||||
setTrainMenuDialog(element)
|
||||
|
||||
${iconSetting == "true" ? `
|
||||
${useVehicleIcons ? `
|
||||
let trainIconUrl = [];
|
||||
operationList
|
||||
.sort((a,b)=>sortOperationalList(a,b,列番データ.toString()))
|
||||
@@ -1173,14 +1196,8 @@ const setStrings = () =>{
|
||||
if(directions != null && directions != undefined){
|
||||
iconTrainDirection = directions ? true : false;
|
||||
}
|
||||
if(iconTrainDirection){
|
||||
if(e.vehicle_img) trainIconUrl.push(e.vehicle_img);
|
||||
else if(e.vehicle_img_right) trainIconUrl.push(e.vehicle_img_right);
|
||||
}else{
|
||||
if(e.vehicle_img_right) trainIconUrl.push(e.vehicle_img_right);
|
||||
else if(e.vehicle_img) trainIconUrl.push(e.vehicle_img);
|
||||
}
|
||||
|
||||
const iconUrl = pickOperationTrainIcon(e, iconTrainDirection);
|
||||
if(iconUrl) trainIconUrl.push(iconUrl);
|
||||
}
|
||||
} else if (e.related_train_ids?.length > 0) {
|
||||
const trainIds = e.related_train_ids.map(
|
||||
@@ -1192,13 +1209,8 @@ const setStrings = () =>{
|
||||
if(directions != null && directions != undefined){
|
||||
iconTrainDirection = directions ? true : false;
|
||||
}
|
||||
if(iconTrainDirection){
|
||||
if(e.vehicle_img) trainIconUrl.push(e.vehicle_img);
|
||||
else if(e.vehicle_img_right) trainIconUrl.push(e.vehicle_img_right);
|
||||
}else{
|
||||
if(e.vehicle_img_right) trainIconUrl.push(e.vehicle_img_right);
|
||||
else if(e.vehicle_img) trainIconUrl.push(e.vehicle_img);
|
||||
}
|
||||
const iconUrl = pickOperationTrainIcon(e, iconTrainDirection);
|
||||
if(iconUrl) trainIconUrl.push(iconUrl);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1212,7 +1224,8 @@ const setStrings = () =>{
|
||||
}
|
||||
else{
|
||||
if(trainDataList.find(e => e.train_id === 列番データ) !== undefined){
|
||||
const trainIconUrl = [trainDataList.find(e => e.train_id === 列番データ).train_info_img];
|
||||
const trainData = trainDataList.find(e => e.train_id === 列番データ);
|
||||
const trainIconUrl = [_preferHubTrainIcon ? (trainData.train_info_img_hub || trainData.train_info_img) : trainData.train_info_img];
|
||||
if(trainIconUrl.length > 0){
|
||||
trainIconUrl.forEach((url,index,array) => {
|
||||
if(url && url != ""){
|
||||
|
||||
@@ -26,7 +26,7 @@ export const TRAIN_TYPE_CONFIG: Record<string, TrainTypeConfig> = {
|
||||
SPCL_Normal: { label: "臨時", typeColor: "#008d07ff", borderColor: "#008d07ff", bgColor: "#ffffffcc", isWanman: false },
|
||||
SPCL_Rapid: { label: "臨時快速", typeColor: "rgba(0, 81, 255, 1)", borderColor: "#0051ffff", bgColor: "#ffffffcc", isWanman: false },
|
||||
SPCL_EXP: { label: "臨時特急", typeColor: "#a52e2eff", borderColor: "#a52e2eff", bgColor: "#ffffffcc", isWanman: false },
|
||||
Party: { label: "団体臨時", typeColor: "#ff7300ff", borderColor: "#ff7300ff", bgColor: "#ffd0a9ff", isWanman: false },
|
||||
Party: { label: "団体", typeColor: "#ff7300ff", borderColor: "#ff7300ff", bgColor: "#ffd0a9ff", isWanman: false },
|
||||
Freight: { label: "貨物", typeColor: "#00869ecc", borderColor: "#00869ecc", bgColor: "#c7c7c7cc", isWanman: false },
|
||||
Forwarding: { label: "回送", typeColor: "#727272cc", borderColor: "#727272cc", bgColor: "#c7c7c7cc", isWanman: false },
|
||||
Trial: { label: "試運転", typeColor: "#727272cc", borderColor: "#727272cc", bgColor: "#c7c7c7cc", isWanman: false },
|
||||
|
||||
+1926
-126
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -60,7 +60,6 @@
|
||||
"expo-keep-awake": "~55.0.4",
|
||||
"expo-linear-gradient": "~55.0.9",
|
||||
"expo-linking": "~55.0.8",
|
||||
"expo-live-activity": "file:./modules/expo-live-activity",
|
||||
"expo-localization": "~55.0.9",
|
||||
"expo-location": "~55.1.4",
|
||||
"expo-notifications": "~55.0.13",
|
||||
@@ -93,7 +92,8 @@
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-webview": "13.16.0",
|
||||
"react-native-worklets": "0.7.2",
|
||||
"typescript": "~5.9.3"
|
||||
"typescript": "~5.9.3",
|
||||
"expo-live-activity": "file:./modules/expo-live-activity"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.14",
|
||||
|
||||
@@ -24,7 +24,7 @@ const noUserscript = args.includes('--no-userscript');
|
||||
|
||||
const options = {
|
||||
mapSwitch: 'false',
|
||||
iconSetting: 'true',
|
||||
iconSetting: 'original',
|
||||
stationMenu: 'false',
|
||||
trainMenu: 'false',
|
||||
uiSetting: (process.env.UI ?? 'tokyo') as string,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const sourcePath = path.join(repoRoot, "ndView.tsx");
|
||||
const outputDir = path.join(repoRoot, "docs", "generated");
|
||||
const outputPath = path.join(outputDir, "jrshikoku-operation-info.user.js");
|
||||
|
||||
const source = fs.readFileSync(sourcePath, "utf8");
|
||||
const match = source.match(/return String\.raw`([\s\S]*?)`;\n};/);
|
||||
if (!match) {
|
||||
throw new Error("Failed to locate buildOperationPageScript body in ndView.tsx");
|
||||
}
|
||||
|
||||
let scriptBody = match[1];
|
||||
|
||||
scriptBody = scriptBody.replace(
|
||||
" window.__jrsNativeOperationLayout = ${nativeLayout};",
|
||||
[
|
||||
" window.__jrsNativeOperationLayout = Object.assign({",
|
||||
" forceSignage: true,",
|
||||
" safeAreaLeft: 0,",
|
||||
" safeAreaRight: 0,",
|
||||
" lineBadges: []",
|
||||
" }, window.__TM_OPERATION_INFO_LAYOUT || {});"
|
||||
].join("\n")
|
||||
);
|
||||
|
||||
const nativePostMessageBlock = [
|
||||
" function postMessage(payload) {",
|
||||
" if (!window.ReactNativeWebView) return;",
|
||||
" payload.type = 'operationInfoCapture';",
|
||||
" window.ReactNativeWebView.postMessage(JSON.stringify(payload));",
|
||||
" }"
|
||||
].join("\n");
|
||||
|
||||
const tampermonkeyPostMessageBlock = [
|
||||
" function downloadCapture(dataUrl, fileName) {",
|
||||
" if (!dataUrl) return;",
|
||||
" if (typeof GM_download === 'function') {",
|
||||
" try {",
|
||||
" GM_download({ url: dataUrl, name: fileName, saveAs: true });",
|
||||
" return;",
|
||||
" } catch (error) {",
|
||||
" console.warn('[JRShikoku TM] GM_download failed, falling back to anchor download.', error);",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" var link = document.createElement('a');",
|
||||
" link.href = dataUrl;",
|
||||
" link.download = fileName || ('operation-info-' + Date.now() + '.png');",
|
||||
" link.rel = 'noopener';",
|
||||
" document.body.appendChild(link);",
|
||||
" link.click();",
|
||||
" link.remove();",
|
||||
" }",
|
||||
"",
|
||||
" function postMessage(payload) {",
|
||||
" if (!payload) return;",
|
||||
" if (payload.error) {",
|
||||
" console.error('[JRShikoku TM] capture failed', payload);",
|
||||
" window.alert('運行情報の画像生成に失敗しました。');",
|
||||
" return;",
|
||||
" }",
|
||||
" downloadCapture(payload.dataUrl, payload.fileName);",
|
||||
" }"
|
||||
].join("\n");
|
||||
|
||||
if (!scriptBody.includes(nativePostMessageBlock)) {
|
||||
throw new Error("Failed to locate React Native postMessage block in ndView.tsx");
|
||||
}
|
||||
scriptBody = scriptBody.replace(nativePostMessageBlock, tampermonkeyPostMessageBlock);
|
||||
|
||||
const header = [
|
||||
"// ==UserScript==",
|
||||
"// @name JR四国 運行情報 Inject",
|
||||
"// @namespace https://github.com/harukin/jrshikoku",
|
||||
"// @version 0.1.0",
|
||||
"// @description Run the JR Shikoku operation-info injection on desktop browsers via Tampermonkey.",
|
||||
"// @match https://www.jr-shikoku.co.jp/info/*",
|
||||
"// @run-at document-idle",
|
||||
"// @grant GM_download",
|
||||
"// ==/UserScript==",
|
||||
"",
|
||||
"/* Generated from ndView.tsx by scripts/generate-operation-info-userscript.mjs. */",
|
||||
"",
|
||||
"window.__TM_OPERATION_INFO_LAYOUT = Object.assign({",
|
||||
" forceSignage: true,",
|
||||
" safeAreaLeft: 0,",
|
||||
" safeAreaRight: 0,",
|
||||
" lineBadges: []",
|
||||
"}, window.__TM_OPERATION_INFO_LAYOUT || {});",
|
||||
""
|
||||
].join("\n");
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
fs.writeFileSync(outputPath, header + scriptBody.trimStart() + "\n", "utf8");
|
||||
console.log(path.relative(repoRoot, outputPath));
|
||||
@@ -37,6 +37,10 @@ import {
|
||||
import { useNotification } from "../stateBox/useNotifications";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { STORAGE_KEYS } from "@/constants/storage";
|
||||
import {
|
||||
normalizeIconDisplayMode,
|
||||
type IconDisplayMode,
|
||||
} from "@/lib/iconDisplayMode";
|
||||
import {
|
||||
getBackendApiBaseUrl,
|
||||
getDiagramTodayUrl,
|
||||
@@ -124,7 +128,9 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
}, []);
|
||||
type boolType = "true" | "false" | undefined;
|
||||
//画面表示関連
|
||||
const [iconSetting, setIconSetting] = useState<boolType>(undefined);
|
||||
const [iconSetting, setIconSetting] = useState<IconDisplayMode | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [mapSwitch, setMapSwitch] = useState<boolType>(undefined);
|
||||
const [stationMenu, setStationMenu] = useState<boolType>(undefined);
|
||||
const [LoadError, setLoadError] = useState(false);
|
||||
@@ -335,8 +341,8 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
//列車アイコンスイッチ
|
||||
ASCore({
|
||||
k: STORAGE_KEYS.ICON_SWITCH,
|
||||
s: setIconSetting,
|
||||
d: "true",
|
||||
s: (value) => setIconSetting(normalizeIconDisplayMode(value)),
|
||||
d: "original",
|
||||
u: true,
|
||||
});
|
||||
//地図スイッチ
|
||||
|
||||
Reference in New Issue
Block a user