Files
jrshikoku/components/Apps/RecordingStatusBar.tsx
harukin-expo-dev-env 3587f72434 feat: add RecordingStatusBar with elapsed timer and keep-awake
- 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>
2026-05-02 00:59:03 +00:00

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",
}}
/>
);
};