Compare commits

..

16 Commits

Author SHA1 Message Date
harukin-expo-dev-env b6e6d2e809 Merge commit '9eff185351a96859f9fb06c08b073c7b3e5cefa3' into develop 2026-06-25 17:46:42 +00:00
harukin-expo-dev-env 9eff185351 7.0.7 release 2026-06-25 17:36:48 +00:00
harukin-expo-dev-env 10e9aece2b Merge commit 'a1b914485134d0c1d1226a45ca43cd35505eeeef' into patch/6.x 2026-06-25 17:35:52 +00:00
harukin-expo-dev-env a1b9144851 Merge commit '16a62aa3bad5279d05dab49160a31388e4f9aef5' into develop 2026-06-25 17:16:17 +00:00
harukin-expo-dev-env 16a62aa3ba 小見出しも並べられるように変更 2026-06-25 16:57:20 +00:00
harukin-expo-dev-env f8395d7270 Hubアイコンを権限餅専用に 2026-06-25 16:20:04 +00:00
harukin-expo-dev-env 5a0fb9f43c 団体臨時から臨時を削除 2026-06-25 16:01:31 +00:00
harukin-expo-dev-env 9d188198a9 Merge commit 'cb1d2665d811ff6cc2a75806654c1e9f823201e7' into develop 2026-06-25 14:27:38 +00:00
harukin-expo-dev-env cb1d2665d8 切り替えスイッチを設置 2026-06-25 14:27:24 +00:00
harukin-expo-dev-env 3bbdd9d0a6 レイアウト機能調整 2026-06-25 13:26:51 +00:00
harukin-expo-dev-env 159f91e55e Merge commit 'daf261b4f842a5fa050cde0643d86654ae45150a' into feature/train-info-edit 2026-06-23 17:12:37 +00:00
harukin-expo-dev-env daf261b4f8 Merge commit '50fa4de9196adbb86313acadbe403d9a64af585a' into develop 2026-06-23 17:12:31 +00:00
harukin-expo-dev-env 50fa4de919 アイコンステータスの表示改善 2026-06-23 17:11:28 +00:00
harukin-expo-dev-env ab2061b2bd unyohubアイコン対応 2026-06-23 16:40:44 +00:00
harukin-expo-dev-env 4dd0775640 feat: add userscript generator for JR Shikoku operation info
- Implemented a script to generate a Tampermonkey userscript from ndView.tsx.
- Extracted and modified the operation page script body to include necessary postMessage functions.
- Added metadata for the userscript including name, namespace, version, and match URL.
- Ensured compatibility with Tampermonkey's GM_download for image capture.
2026-06-20 06:07:39 +00:00
harukin-expo-dev-env 4f81431ccd feat: 列車情報のキャプチャ機能を追加し、スタイルを改善 2026-06-10 05:04:09 +00:00
32 changed files with 4136 additions and 1103 deletions
+6 -3
View File
@@ -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");
}}
/>
);
};
+56 -39
View File
@@ -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 }}
+35 -9
View File
@@ -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>
);
};
+7 -1
View File
@@ -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
+6 -137
View File
@@ -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>
);
+45 -3
View File
@@ -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>
);
}
+15 -11
View File
@@ -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>
);
+7 -201
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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;
+1 -1
View File
@@ -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
View File
@@ -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",
+17
View File
@@ -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";
+60
View File
@@ -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;
};
+28
View File
@@ -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,
);
+97
View File
@@ -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 || "",
},
];
};
-272
View File
@@ -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");
};
-115
View File
@@ -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),
};
};
+32 -19
View File
@@ -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 != ""){
+1 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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));
+9 -3
View File
@@ -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,
});
//地図スイッチ