- RecordingStatusBar.tsx: shown during recorderState === 'recording'
- absolute positioned red bar at top of map screen (zIndex 2000)
- blinking REC dot (700ms interval) + REC label + MM:SS elapsed timer
- snapshot count display (right side)
- pointerEvents="none" so it doesn't block map interaction
- activateKeepAwakeAsync while recording, deactivated on stop/unmount
(same pattern as FixedPositionBox, tag = 'recording-status-bar')
- Apps.tsx: render <RecordingStatusBar /> alongside PlaybackTimeline
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
164 lines
4.4 KiB
TypeScript
164 lines
4.4 KiB
TypeScript
import React, { FC, useEffect, useRef, useState } from "react";
|
|
import { View, Text, AppState, InteractionManager } from "react-native";
|
|
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { useTrainMenu } from "../../stateBox/useTrainMenu";
|
|
import { useThemeColors } from "../../lib/theme";
|
|
import { useResponsive } from "../../lib/responsive";
|
|
|
|
const KEEP_AWAKE_TAG = "recording-status-bar";
|
|
|
|
const isActivityUnavailableError = (error: unknown) =>
|
|
String(error).includes("The current activity is no longer available");
|
|
|
|
/**
|
|
* 録画中に走行位置画面上部に表示するステータスバー。
|
|
* PlaybackTimeline と同じ absolute 配置。
|
|
* 録画中はスリープを抑制する。
|
|
*/
|
|
export const RecordingStatusBar: FC = () => {
|
|
const { top } = useSafeAreaInsets();
|
|
const { colors } = useThemeColors();
|
|
const { moderateScale } = useResponsive();
|
|
const { recorderState, recordingSnapshotCount } = useTrainMenu();
|
|
|
|
// 経過時間(秒)
|
|
const [elapsedSec, setElapsedSec] = useState(0);
|
|
const startTimeRef = useRef<number>(Date.now());
|
|
|
|
// 録画開始時にタイマーをリセットして1秒ごとに更新
|
|
useEffect(() => {
|
|
if (recorderState !== "recording") {
|
|
setElapsedSec(0);
|
|
return;
|
|
}
|
|
startTimeRef.current = Date.now();
|
|
setElapsedSec(0);
|
|
const timer = setInterval(() => {
|
|
setElapsedSec(Math.floor((Date.now() - startTimeRef.current) / 1000));
|
|
}, 1000);
|
|
return () => clearInterval(timer);
|
|
}, [recorderState]);
|
|
|
|
// 録画中はスリープ抑制
|
|
useEffect(() => {
|
|
if (recorderState !== "recording") return;
|
|
if (__DEV__) return;
|
|
|
|
let mounted = true;
|
|
|
|
const activate = async () => {
|
|
if (!mounted || AppState.currentState !== "active") return;
|
|
try {
|
|
await activateKeepAwakeAsync(KEEP_AWAKE_TAG);
|
|
} catch (error) {
|
|
if (!isActivityUnavailableError(error)) {
|
|
console.warn("RecordingStatusBar: failed to activate keep awake", error);
|
|
}
|
|
}
|
|
};
|
|
|
|
const interactionHandle = InteractionManager.runAfterInteractions(() => {
|
|
void activate();
|
|
});
|
|
|
|
const subscription = AppState.addEventListener("change", (state) => {
|
|
if (state === "active") {
|
|
void activate();
|
|
return;
|
|
}
|
|
deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => {});
|
|
});
|
|
|
|
return () => {
|
|
mounted = false;
|
|
interactionHandle.cancel();
|
|
subscription.remove();
|
|
deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => {});
|
|
};
|
|
}, [recorderState]);
|
|
|
|
if (recorderState !== "recording") return null;
|
|
|
|
const minutes = Math.floor(elapsedSec / 60);
|
|
const seconds = elapsedSec % 60;
|
|
const timeLabel = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
top,
|
|
left: 0,
|
|
right: 0,
|
|
zIndex: 2000,
|
|
backgroundColor: "rgba(229, 57, 53, 0.92)",
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 7,
|
|
gap: 10,
|
|
}}
|
|
pointerEvents="none"
|
|
>
|
|
{/* 点滅 REC ドット */}
|
|
<BlinkDot />
|
|
|
|
<Text
|
|
style={{
|
|
color: "#fff",
|
|
fontWeight: "bold",
|
|
fontSize: moderateScale(13),
|
|
letterSpacing: 1,
|
|
}}
|
|
>
|
|
REC
|
|
</Text>
|
|
|
|
<Text
|
|
style={{
|
|
color: "#fff",
|
|
fontSize: moderateScale(15),
|
|
fontWeight: "bold",
|
|
fontVariant: ["tabular-nums"],
|
|
letterSpacing: 1,
|
|
}}
|
|
>
|
|
{timeLabel}
|
|
</Text>
|
|
|
|
<View style={{ flex: 1 }} />
|
|
|
|
<Text
|
|
style={{
|
|
color: "rgba(255,255,255,0.85)",
|
|
fontSize: moderateScale(11),
|
|
}}
|
|
>
|
|
{recordingSnapshotCount} コマ
|
|
</Text>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
/** 1秒ごとに点滅する録画インジケータードット */
|
|
const BlinkDot: FC = () => {
|
|
const [visible, setVisible] = useState(true);
|
|
useEffect(() => {
|
|
const timer = setInterval(() => setVisible((v) => !v), 700);
|
|
return () => clearInterval(timer);
|
|
}, []);
|
|
return (
|
|
<View
|
|
style={{
|
|
width: 10,
|
|
height: 10,
|
|
borderRadius: 5,
|
|
backgroundColor: visible ? "#fff" : "transparent",
|
|
borderWidth: 1.5,
|
|
borderColor: "#fff",
|
|
}}
|
|
/>
|
|
);
|
|
};
|