Files
jrshikoku/components/Apps/PlaybackTimeline.tsx
harukin-expo-dev-env 4f4d3cad0a fix: crash on playback start + support multiple recordings
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>
2026-05-02 00:52:30 +00:00

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