Crash fix (React Hooks violation): - PlaybackTimeline.tsx: move ALL hooks (useRef for PanResponder, useCallback) to before the early return; use seekRef/totalRef to share values into PanResponder handlers without stale closure issues Multiple recordings support: - trainRecorder.ts: redesign to id-based multi-recording system - RecordingMeta type for lightweight list (no snapshots) - TrainRecording now includes id field - saveRecording/loadRecordingById/deleteRecordingById/loadRecordingList - migrateOldRecording() migrates old single MOCK_RECORDING key on first launch - constants/storage.ts: add MOCK_RECORDINGS_INDEX + MOCK_RECORDING_DATA_PREFIX keys - useTrainMenu: savedRecording → recordingList (RecordingMeta[]) + activeRecording (TrainRecording|null) - startPlayback(id) loads full recording on demand - deleteRecording(id) deletes by id and refreshes list - stopPlayback clears activeRecording - DataSourceSettings: recording list UI - shows all recordings with date/time, snapshot count, duration - ▶ play and 削除 buttons per row - recording/playing status indicator Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
245 lines
7.7 KiB
TypeScript
245 lines
7.7 KiB
TypeScript
import React, { FC, useRef, useCallback } from "react";
|
|
import {
|
|
View,
|
|
Text,
|
|
TouchableOpacity,
|
|
PanResponder,
|
|
LayoutChangeEvent,
|
|
} from "react-native";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import dayjs from "dayjs";
|
|
import { useTrainMenu } from "../../stateBox/useTrainMenu";
|
|
import { useThemeColors } from "../../lib/theme";
|
|
import { useResponsive } from "../../lib/responsive";
|
|
|
|
/**
|
|
* 再生中に走行位置WebViewの上部に表示するタイムラインコントローラー。
|
|
* FixedPositionBox と同じ absolute 配置で zIndex を上に設定する。
|
|
*/
|
|
export const PlaybackTimeline: FC = () => {
|
|
const { top } = useSafeAreaInsets();
|
|
const { colors, fixed } = useThemeColors();
|
|
const { moderateScale } = useResponsive();
|
|
const {
|
|
recorderState,
|
|
activeRecording,
|
|
playbackIndex,
|
|
playbackPaused,
|
|
stopPlayback,
|
|
pausePlayback,
|
|
resumePlayback,
|
|
seekToSnapshot,
|
|
} = useTrainMenu();
|
|
|
|
// ─── すべてのフックは早期returnより前に呼ぶ ───
|
|
const trackWidthRef = useRef(1);
|
|
|
|
// PanResponder のハンドラ内で最新値を参照するために ref を使う
|
|
const seekRef = useRef(seekToSnapshot);
|
|
seekRef.current = seekToSnapshot;
|
|
const totalRef = useRef(0);
|
|
|
|
const panResponder = useRef(
|
|
PanResponder.create({
|
|
onStartShouldSetPanResponder: () => true,
|
|
onMoveShouldSetPanResponder: () => true,
|
|
onPanResponderGrant: (evt) => {
|
|
const x = evt.nativeEvent.locationX;
|
|
const idx = Math.round((x / trackWidthRef.current) * (totalRef.current - 1));
|
|
seekRef.current(Math.max(0, Math.min(idx, totalRef.current - 1)));
|
|
},
|
|
onPanResponderMove: (evt) => {
|
|
const x = evt.nativeEvent.locationX;
|
|
const idx = Math.round((x / trackWidthRef.current) * (totalRef.current - 1));
|
|
seekRef.current(Math.max(0, Math.min(idx, totalRef.current - 1)));
|
|
},
|
|
})
|
|
).current;
|
|
|
|
const onTrackLayout = useCallback((e: LayoutChangeEvent) => {
|
|
trackWidthRef.current = e.nativeEvent.layout.width;
|
|
}, []);
|
|
|
|
const btnSize = moderateScale(36);
|
|
const iconSize = moderateScale(20);
|
|
|
|
// ─── 早期return ───
|
|
if (recorderState !== "playing" || !activeRecording) return null;
|
|
|
|
const total = activeRecording.snapshots.length;
|
|
totalRef.current = total; // PanResponder が参照する最新値を更新
|
|
|
|
const snap = activeRecording.snapshots[playbackIndex];
|
|
|
|
// スナップショット時刻 = 録画開始時刻 + elapsed
|
|
const snapTime = dayjs(activeRecording.recordedAt).add(snap.t, "ms");
|
|
const timeLabel = snapTime.format("HH:mm:ss");
|
|
|
|
// 録画の総時間をフォーマット
|
|
const totalSec = Math.round(activeRecording.durationMs / 1000);
|
|
const totalLabel =
|
|
totalSec >= 60
|
|
? `${Math.floor(totalSec / 60)}:${String(totalSec % 60).padStart(2, "0")}`
|
|
: `${totalSec}s`;
|
|
|
|
const progress = total > 1 ? playbackIndex / (total - 1) : 0;
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
top,
|
|
left: 0,
|
|
right: 0,
|
|
zIndex: 2000,
|
|
backgroundColor: colors.surface + "f2", // 少し透過
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.borderSecondary,
|
|
paddingHorizontal: 12,
|
|
paddingTop: 6,
|
|
paddingBottom: 8,
|
|
}}
|
|
pointerEvents="box-none"
|
|
>
|
|
{/* 上段: ボタン + 時刻 + コマ数 */}
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
}}
|
|
>
|
|
{/* 先頭コマへ */}
|
|
<TouchableOpacity
|
|
onPress={() => seekToSnapshot(0)}
|
|
style={[styles.btn(btnSize, colors.borderSecondary)]}
|
|
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
|
|
>
|
|
<Ionicons name="play-skip-back" size={iconSize} color={colors.textPrimary} />
|
|
</TouchableOpacity>
|
|
|
|
{/* 前のコマ */}
|
|
<TouchableOpacity
|
|
onPress={() => seekToSnapshot(playbackIndex - 1)}
|
|
style={[styles.btn(btnSize, colors.borderSecondary)]}
|
|
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
|
|
>
|
|
<Ionicons name="play-back" size={iconSize} color={colors.textPrimary} />
|
|
</TouchableOpacity>
|
|
|
|
{/* 再生 / 一時停止 */}
|
|
<TouchableOpacity
|
|
onPress={playbackPaused ? resumePlayback : pausePlayback}
|
|
style={[styles.btn(btnSize, fixed.primary)]}
|
|
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
|
|
>
|
|
<Ionicons
|
|
name={playbackPaused ? "play" : "pause"}
|
|
size={iconSize}
|
|
color={fixed.textOnPrimary}
|
|
/>
|
|
</TouchableOpacity>
|
|
|
|
{/* 次のコマ */}
|
|
<TouchableOpacity
|
|
onPress={() => seekToSnapshot(playbackIndex + 1)}
|
|
style={[styles.btn(btnSize, colors.borderSecondary)]}
|
|
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
|
|
>
|
|
<Ionicons name="play-forward" size={iconSize} color={colors.textPrimary} />
|
|
</TouchableOpacity>
|
|
|
|
{/* 末尾コマへ */}
|
|
<TouchableOpacity
|
|
onPress={() => seekToSnapshot(total - 1)}
|
|
style={[styles.btn(btnSize, colors.borderSecondary)]}
|
|
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
|
|
>
|
|
<Ionicons name="play-skip-forward" size={iconSize} color={colors.textPrimary} />
|
|
</TouchableOpacity>
|
|
|
|
{/* スペーサー */}
|
|
<View style={{ flex: 1 }} />
|
|
|
|
{/* 時刻表示 */}
|
|
<View style={{ alignItems: "flex-end" }}>
|
|
<Text style={{ fontSize: moderateScale(15), fontWeight: "bold", color: colors.textPrimary, fontVariant: ["tabular-nums"] }}>
|
|
{timeLabel}
|
|
</Text>
|
|
<Text style={{ fontSize: moderateScale(10), color: colors.textSecondary, fontVariant: ["tabular-nums"] }}>
|
|
{playbackIndex + 1}/{total} {totalLabel}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* 停止ボタン */}
|
|
<TouchableOpacity
|
|
onPress={stopPlayback}
|
|
style={[styles.btn(btnSize, "#e53935"), { marginLeft: 6 }]}
|
|
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
|
|
>
|
|
<Ionicons name="stop" size={iconSize} color="#fff" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* 下段: スクラバートラック */}
|
|
<View
|
|
style={{ marginTop: 6 }}
|
|
onLayout={onTrackLayout}
|
|
{...panResponder.panHandlers}
|
|
>
|
|
{/* トラック背景 */}
|
|
<View
|
|
style={{
|
|
height: 6,
|
|
borderRadius: 3,
|
|
backgroundColor: colors.borderSecondary,
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
{/* 進捗バー */}
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
left: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: `${progress * 100}%`,
|
|
backgroundColor: fixed.primary,
|
|
borderRadius: 3,
|
|
}}
|
|
/>
|
|
</View>
|
|
{/* ドラッグハンドル */}
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
top: -4,
|
|
left: `${progress * 100}%`,
|
|
marginLeft: -8,
|
|
width: 14,
|
|
height: 14,
|
|
borderRadius: 7,
|
|
backgroundColor: fixed.primary,
|
|
borderWidth: 2,
|
|
borderColor: fixed.textOnPrimary,
|
|
elevation: 2,
|
|
}}
|
|
/>
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
// ヘルパー: ボタンスタイル生成
|
|
const styles = {
|
|
btn: (size: number, bg: string) => ({
|
|
width: size,
|
|
height: size,
|
|
borderRadius: size / 2,
|
|
backgroundColor: bg,
|
|
alignItems: "center" as const,
|
|
justifyContent: "center" as const,
|
|
}),
|
|
};
|