Files
jrshikoku/components/Apps/FixedPositionBox/FixedStationBox.tsx

663 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import lineColorList from "@/assets/originData/lineColorList";
import { StationNumberMaker } from "@/components/駅名表/StationNumberMaker";
import { checkDuplicateTrainData } from "@/lib/checkDuplicateTrainData";
import {
CustomTrainData,
eachTrainDiagramType,
StationProps,
} from "@/lib/CommonTypes";
import { getCurrentTrainData } from "@/lib/getCurrentTrainData";
import { getTrainDelayStatus } from "@/lib/getTrainDelayStatus";
import { getTrainType } from "@/lib/getTrainType";
import { objectIsEmpty } from "@/lib/objectIsEmpty";
import { getTime, trainTimeFiltering } from "@/lib/trainTimeFiltering";
import betweenData from "@/assets/originData/between";
import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
import { useAreaInfo } from "@/stateBox/useAreaInfo";
import { useCurrentTrain } from "@/stateBox/useCurrentTrain";
import { useStationList } from "@/stateBox/useStationList";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import { FC, useCallback, useEffect, useRef, useState } from "react";
import { LayoutAnimation, PermissionsAndroid, Platform, Text, TouchableOpacity, View } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withSequence,
withTiming,
cancelAnimation,
} from "react-native-reanimated";
import { SheetManager } from "react-native-actions-sheet";
import { useThemeColors } from "@/lib/theme";
import {
startStationLockActivity,
updateStationLockActivity,
endStationLockActivity,
isAvailable as isLiveActivityAvailable,
StationTrainInfo,
} from "expo-live-activity";
import { Asset } from "expo-asset";
import { useAudioPlayer, setAudioModeAsync } from "expo-audio";
import type { AudioSource } from "expo-audio";
import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants";
// ── 遅延速報サウンド設定 ──
// 将来のファイル差し替えに備えて定数で管理
const DELAY_ANNOUNCEMENT_SOUND = require("../../../assets/sound/delay-support.wav");
// 遅延案内の再生間隔: 120〜180 秒をランダムで選択2〜3分ごと
const getAnnouncementIntervalMs = () =>
(Math.floor(Math.random() * 60) + 120) * 1000;
type props = {
stationID: string;
};
export const FixedStation: FC<props> = ({ stationID }) => {
const { colors, fixed } = useThemeColors();
const { mapSwitch } = useTrainMenu();
const {
currentTrain,
fixedPosition,
setFixedPosition,
fixedPositionSize,
setFixedPositionSize,
} = useCurrentTrain();
const { getStationDataFromId } = useStationList();
const { stationList } = useStationList();
const { navigate } = useNavigation();
const [station, setStation] = useState<StationProps[]>([]);
// GPS追従中の点滅アニメーション
const pulseAnim = useSharedValue(1);
const isGpsFollowing = fixedPosition?.type === "nearestStation";
useEffect(() => {
if (!isGpsFollowing) {
cancelAnimation(pulseAnim);
pulseAnim.value = 1;
return;
}
pulseAnim.value = withRepeat(
withSequence(
withTiming(0.4, { duration: 600 }),
withTiming(1, { duration: 600 })
),
-1
);
}, [isGpsFollowing]);
const pulseStyle = useAnimatedStyle(() => ({
opacity: pulseAnim.value,
}));
useEffect(() => {
const data = getStationDataFromId(stationID);
setStation(data);
}, [stationID]);
const lineColor =
station.length > 0
? lineColorList[station[0]?.StationNumber.slice(0, 1)]
: "white";
////
const { allTrainDiagram, allCustomTrainData } = useAllTrainDiagram();
const { areaStationID } = useAreaInfo();
const [stationDiagram, setStationDiagram] = useState({}); //当該駅の全時刻表
const [isInfoArea, setIsInfoArea] = useState(false);
useEffect(() => {
// 現在の駅に停車するダイヤを作成する副作用[列車ダイヤと現在駅情報]
if (!allTrainDiagram) {
setStationDiagram({});
return;
}
if (station.length == 0) {
setStationDiagram({});
return;
}
let returnData = {};
Object.keys(allTrainDiagram).forEach((key) => {
if (allTrainDiagram[key].match(station[0].Station_JP + ",")) {
returnData[key] = allTrainDiagram[key];
}
});
setStationDiagram(returnData);
setIsInfoArea(station.some((s) => areaStationID.includes(s.StationNumber)));
}, [allTrainDiagram, station]);
const [trainTimeAndNumber, setTrainTimeAndNumber] = useState<
eachTrainDiagramType[]
>([]);
useEffect(() => {
//現在の駅に停車する列車から時刻を切り出してLEDベースにフォーマット
if (objectIsEmpty(stationDiagram)) return () => {};
const getTimeData = getTime(stationDiagram, station[0]);
setTrainTimeAndNumber(getTimeData);
}, [stationDiagram]);
const [selectedTrain, setSelectedTrain] = useState<eachTrainDiagramType[]>(
[]
);
// ── Live Notification ──
const [liveNotifyId, setLiveNotifyId] = useState<string | null>(null);
const liveNotifyIdRef = useRef<string | null>(null);
const hasStartedRef = useRef(false);
const [tick, setTick] = useState(0);
useEffect(() => {
if (!trainTimeAndNumber) return () => {};
if (!currentTrain) return () => {};
const data = trainTimeAndNumber
.filter((d) => currentTrain.map((m) => m.num).includes(d.train)) //現在の列車に絞る[ToDo]
.filter((d) => trainTimeFiltering({ d, currentTrain, station })) //時間フィルター
.filter((d) => !d.isThrough)
.filter((d) => d.lastStation != station[0].Station_JP); //最終列車表示設定
setSelectedTrain(data);
}, [trainTimeAndNumber, currentTrain, tick /*liveActivity periodic refresh*/]);
useEffect(() => {
liveNotifyIdRef.current = liveNotifyId;
}, [liveNotifyId]);
// Live Activity 起動中は 60 秒ごとに selectedTrain を強制再計算して時刻フィルタを再適用
useEffect(() => {
if (!liveNotifyId) return;
const interval = setInterval(() => setTick((t) => t + 1), 60000);
return () => clearInterval(interval);
}, [liveNotifyId]);
useEffect(() => {
return () => {
if (liveNotifyIdRef.current) {
endStationLockActivity(liveNotifyIdRef.current).catch(() => {});
}
};
}, []);
// ── 遅延速報サウンド ──
const [soundDelayEnabled, setSoundDelayEnabled] = useState(false);
const [resolvedDelaySound, setResolvedDelaySound] = useState<AudioSource>(null);
// 設定値の読み込み
useEffect(() => {
AS.getItem(STORAGE_KEYS.SOUND_DELAY_ANNOUNCEMENT)
.then((v) => setSoundDelayEnabled(v === true || v === "true"))
.catch(() => {});
}, []);
// サウンドアセットの解決 — 設定が ON のときだけ実行
useEffect(() => {
if (!soundDelayEnabled) {
setResolvedDelaySound(null);
return;
}
let mounted = true;
const resolve = async () => {
try {
const asset = Asset.fromModule(DELAY_ANNOUNCEMENT_SOUND);
await asset.downloadAsync();
if (!mounted || !asset.localUri) return;
const source =
Platform.OS === "android"
? { uri: asset.localUri.replace(/^\/file:\/\//, "") }
: { uri: asset.localUri };
setResolvedDelaySound(source);
} catch (e) {
console.warn("[DelaySound] Failed to resolve asset", e);
}
};
resolve();
return () => {
mounted = false;
};
}, [soundDelayEnabled]);
const delayAnnouncementPlayer = useAudioPlayer(resolvedDelaySound);
// 最新の状態をrefで保持intervalの安定したコールバックから参照するため
const soundDelayEnabledRef = useRef(soundDelayEnabled);
const delayAnnouncementPlayerRef = useRef(delayAnnouncementPlayer);
useEffect(() => {
soundDelayEnabledRef.current = soundDelayEnabled;
}, [soundDelayEnabled]);
useEffect(() => {
delayAnnouncementPlayerRef.current = delayAnnouncementPlayer;
}, [delayAnnouncementPlayer]);
// 5分以上の遅延がある列車が存在するかを判定
const hasDelayedTrain = selectedTrain.some((d) => {
const trainData = checkDuplicateTrainData(
currentTrain.filter((a) => a.num === d.train),
stationList
);
if (!trainData) return false;
const delay = trainData.delay;
return typeof delay === "number" && delay >= 5;
});
const hasDelayedTrainRef = useRef(hasDelayedTrain);
useEffect(() => {
hasDelayedTrainRef.current = hasDelayedTrain;
}, [hasDelayedTrain]);
// 遅延速報サウンド再生インターバル
// マウント時に一度だけセットアップし、30秒ごとに条件を確認
useEffect(() => {
const CHECK_INTERVAL_MS = 30 * 1000;
let nextPlayAt = Date.now() + getAnnouncementIntervalMs();
const handle = setInterval(() => {
if (!soundDelayEnabledRef.current || !hasDelayedTrainRef.current) {
// 条件が成立していない間はタイマーをリセットし続ける
nextPlayAt = Date.now() + getAnnouncementIntervalMs();
return;
}
const now = Date.now();
if (now >= nextPlayAt) {
nextPlayAt = now + getAnnouncementIntervalMs();
const player = delayAnnouncementPlayerRef.current;
setAudioModeAsync({
playsInSilentMode: true,
shouldPlayInBackground: true,
interruptionMode: "duckOthers",
})
.then(() => {
if (player.playing) player.pause();
player.volume = 1;
return player.seekTo(0);
})
.then(() => player.play())
.catch((e) => console.warn("[DelaySound] Failed to play", e));
}
}, CHECK_INTERVAL_MS);
return () => clearInterval(handle);
}, []); // マウント時に一度だけ設定
const buildTrainsInfo = useCallback((): StationTrainInfo[] => {
const stationName = station[0]?.Station_JP;
// between.ts から当駅の「BetweenStation」を引き当てる。
// Androidバックグラウンドはこの文字列で Pos と照合し、直前セクションへの接近を判定する。
const getSectionStation = (): string => {
const entry = betweenData.find((b) =>
b.Datas.some((s) => s.StationName === stationName)
);
return entry?.BetweenStation || "";
};
const sectionStation = getSectionStation();
return selectedTrain.slice(0, 7).map((d) => {
const customData = getCurrentTrainData(
d.train,
currentTrain,
allCustomTrainData
);
const currentTrainDataForTrain = checkDuplicateTrainData(
currentTrain.filter((a) => a.num === d.train),
stationList
);
const { name, color: typeColor } = getTrainType({ type: customData.type, whiteMode: true });
const delayStatus = `${getTrainDelayStatus(
currentTrainDataForTrain,
station[0]?.Station_JP
)}`;
return {
time: d.time,
typeName: name,
trainName: customData.train_name || "",
destination: d.lastStation,
platform: "",
delayStatus: delayStatus || "定刻",
typeColor: typeColor || "",
trainNumber: d.train,
sectionStation,
};
});
}, [selectedTrain, currentTrain, allCustomTrainData, stationList, station]);
useEffect(() => {
if (!liveNotifyId || station.length === 0) return;
const trains = buildTrainsInfo();
updateStationLockActivity(liveNotifyId, {
nextTrainTime: trains[0]?.time || "",
nextTrainDestination: trains[0]?.destination || "",
nextTrainPlatform: trains[0]?.platform || "",
followingTrainTime: trains[1]?.time || "",
followingTrainDestination: trains[1]?.destination || "",
stationName: station[0]?.Station_JP,
stationNumber: station[0]?.StationNumber,
lineColor,
trains,
}).catch(() => {});
}, [selectedTrain, currentTrain, liveNotifyId, buildTrainsInfo]);
// バナー表示と同時にLive Activityを自動開始selectedTrainが揃ってから
// iOSのみ一時的に無効化中Androidは有効
useEffect(() => {
// iOSのLive Activityは無効化
if (Platform.OS === 'ios') return;
hasStartedRef.current = true;
const startActivity = async () => {
if (Platform.OS === 'android' && Platform.Version >= 33) {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS
);
if (granted !== PermissionsAndroid.RESULTS.GRANTED) return;
}
const trains = buildTrainsInfo();
try {
const id = await startStationLockActivity({
stationName: station[0]?.Station_JP || "",
nextTrainTime: trains[0]?.time || "",
nextTrainDestination: trains[0]?.destination || "",
nextTrainPlatform: trains[0]?.platform || "",
followingTrainTime: trains[1]?.time || "",
followingTrainDestination: trains[1]?.destination || "",
stationNumber: station[0]?.StationNumber,
lineColor,
trains,
});
setLiveNotifyId(id);
} catch (e) {
console.warn('[LiveNotify] start error:', e);
hasStartedRef.current = false;
}
};
startActivity();
}, [station, selectedTrain]);
return (
<View
style={{ display: "flex", flexDirection: "column", flex: 1 }}
pointerEvents="box-none"
>
<TouchableOpacity
style={{
flex: 1,
flexDirection: "row",
borderBottomColor: lineColor,
borderBottomWidth: 2,
position: "relative",
}}
activeOpacity={1}
onPress={() => {
const payload = {
currentStation: station,
navigate,
goTo: "menu",
onExit: () => SheetManager.hide("StationDetailView"),
};
//@ts-ignore
SheetManager.show("StationDetailView", { payload });
}}
>
<View
style={{
flex: 3,
flexDirection: "column",
alignContent: "center",
alignSelf: "center",
alignItems: "center",
height: "100%",
backgroundColor: colors.background,
}}
>
<View
style={{
backgroundColor: lineColor,
flexDirection: "row",
width: "100%",
alignContent: "center",
alignItems: "center",
height: 22,
overflow: "hidden",
paddingLeft: 5,
}}
>
<StationNumberMaker
currentStation={station}
singleSize={18}
useEach={true}
/>
<Text
style={{
fontSize: 14,
textAlignVertical: "center",
margin: 0,
padding: 0,
paddingLeft: 5,
flex: 1,
color: fixed.textOnPrimary,
}}
>
{station[0]?.Station_JP}
</Text>
<View
style={{
backgroundColor: colors.background,
width: 6,
borderLeftColor: lineColor,
borderTopColor: lineColor,
borderBottomColor: colors.background,
borderRightColor: colors.background,
borderBottomWidth: 18,
borderLeftWidth: 10,
borderRightWidth: 0,
borderTopWidth: 5,
height: 20,
}}
/>
</View>
<View
style={{
height: "100%",
backgroundColor: colors.background,
flex: 1,
}}
>
<Text style={{ fontSize: 18, color: colors.text }}></Text>
</View>
</View>
<View
style={{
flex: 5,
flexDirection: "column",
backgroundColor: colors.background,
borderTopWidth: 5,
borderTopColor: lineColor,
overflow: "hidden",
}}
>
{selectedTrain.length > 0 ? (
selectedTrain.map((d) => (
<FixedStationBoxEachTrain
d={d}
station={station[0]}
displaySize={fixedPositionSize}
key={d.train + "-fixedStationBox"}
/>
))
) : (
<View style={{ backgroundColor: colors.background, flex: 1 }}>
<Text style={{ fontSize: parseInt("11%"), color: colors.text }}>
</Text>
</View>
)}
</View>
</TouchableOpacity>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
borderTopColor: lineColor,
borderTopWidth: 2,
}}
pointerEvents="box-none"
>
<TouchableOpacity
style={{
flexDirection: "row",
alignItems: "center",
}}
onPress={() => {
setFixedPosition({ type: null, value: null });
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
backgroundColor: lineColor,
paddingHorizontal: 5,
height: 26,
}}
>
{isGpsFollowing ? (
<Animated.View
style={[{
flexDirection: "row",
alignItems: "center",
}, pulseStyle]}
>
<Ionicons name="navigate" size={15} color={fixed.textOnPrimary} />
<Text style={{ color: fixed.textOnPrimary, fontSize: 15, paddingRight: 5, paddingLeft: 3 }}>
GPS追従中
</Text>
</Animated.View>
) : (
<>
<Ionicons name="lock-closed" size={15} color={fixed.textOnPrimary} />
<Text style={{ color: fixed.textOnPrimary, fontSize: 15, paddingRight: 5 }}>
</Text>
</>
)}
<Ionicons name="close" size={15} color={fixed.textOnPrimary} />
</View>
<View
style={{
backgroundColor: "#0000",
width: 6,
borderLeftColor: lineColor,
borderTopColor: lineColor,
borderBottomColor: "#0000",
borderRightColor: "#0000",
borderBottomWidth: 26,
borderLeftWidth: 10,
borderRightWidth: 0,
borderTopWidth: 0,
height: 26,
}}
/>
</TouchableOpacity>
<TouchableOpacity
style={{
flexDirection: "row",
alignItems: "center",
}}
onPress={() => {
LayoutAnimation.configureNext({
duration: 500,
update: { type: "spring", springDamping: 0.7 },
});
if (fixedPositionSize === 226) {
setFixedPositionSize(mapSwitch == "true" ? 76 : 80);
} else {
setFixedPositionSize(226);
}
}}
>
<View
style={{
backgroundColor: "#0000",
width: 6,
borderLeftColor: "#0000",
borderTopColor: lineColor,
borderBottomColor: "#0000",
borderRightColor: lineColor,
borderBottomWidth: 26,
borderLeftWidth: 0,
borderRightWidth: 10,
borderTopWidth: 0,
height: 26,
}}
/>
<View
style={{
flexDirection: "row",
alignItems: "center",
backgroundColor: lineColor,
paddingHorizontal: 5,
height: 26,
}}
pointerEvents="none"
>
<Ionicons
name={fixedPositionSize == 226 ? "chevron-up" : "chevron-down"}
size={15}
color={fixed.textOnPrimary}
/>
<Text
style={{
color: fixed.textOnPrimary,
paddingRight: 5,
backgroundColor: lineColor,
fontSize: 15,
}}
>
{fixedPositionSize == 226
? "時刻表を縮小する"
: "時刻表を展開する"}
</Text>
</View>
</TouchableOpacity>
</View>
</View>
);
};
const FixedStationBoxEachTrain = ({ d, station, displaySize }) => {
const { colors, isDark } = useThemeColors();
const { currentTrain } = useCurrentTrain();
const { stationList } = useStationList();
const { allCustomTrainData } = useAllTrainDiagram();
const currentTrainData = checkDuplicateTrainData(
currentTrain.filter((a) => a.num == d.train),
stationList
);
const trainDelayStatus = `${getTrainDelayStatus(
currentTrainData,
station.Station_JP
)}`;
const [train, setTrain] = useState<CustomTrainData>(
getCurrentTrainData(d.train, currentTrain, allCustomTrainData)
);
useEffect(() => {
setTrain(getCurrentTrainData(d.train, currentTrain, allCustomTrainData));
}, [currentTrain, d.train]);
const { name, color } = getTrainType({ type: train.type, whiteMode: !isDark });
return (
<View
style={{
backgroundColor: colors.background,
flexDirection: "row",
height: displaySize == 226 ? "7.5%" : "33%",
overflow: "visible",
}}
>
<Text style={{ fontSize: parseInt("11%"), flex: 3, color: colors.text }}>{d.time}</Text>
<Text style={{ fontSize: parseInt("11%"), flex: 4, color }}>{name}</Text>
<Text style={{ fontSize: parseInt("11%"), flex: 4, color: colors.text }}>
{d.lastStation}
</Text>
<Text style={{ fontSize: parseInt("11%"), flex: 3, color: colors.text }}>
{trainDelayStatus}
</Text>
</View>
);
};