feat: add playback timeline UI with pause/resume/seek controls

- useTrainMenu: add playbackPaused state, pausePlayback/resumePlayback/seekToSnapshot
  - playback loop now respects playbackPaused (no timer when paused)
  - seekToSnapshot pauses playback and immediately applies snapshot data
  - expose playbackIndex, playbackPaused + new functions in context value
- PlaybackTimeline.tsx: new component shown when recorderState === 'playing'
  - absolute positioned bar at top of map screen (zIndex 2000)
  - prev/play-pause/next frame buttons + skip-to-start/end
  - time display (HH:mm:ss) + snapshot counter (n/total) + total duration
  - PanResponder-based scrubber track with filled progress bar and draggable thumb
- Apps.tsx: render <PlaybackTimeline /> alongside FixedPositionBox
- Remove MockApiToggle.tsx (no longer used since settings-only toggle)

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
harukin-expo-dev-env
2026-05-02 00:42:56 +00:00
co-authored by Copilot
parent 597151e875
commit 43dcaab643
3 changed files with 272 additions and 3 deletions
+2
View File
@@ -24,6 +24,7 @@ import { MapsButton } from "./Apps/MapsButton";
import { ReloadButton } from "./Apps/ReloadButton";
import { useStationList } from "../stateBox/useStationList";
import { FixedPositionBox } from "./Apps/FixedPositionBox";
import { PlaybackTimeline } from "./Apps/PlaybackTimeline";
export default function Apps() {
const { webview, fixedPosition, setFixedPosition } = useCurrentTrain();
@@ -119,6 +120,7 @@ export default function Apps() {
/>
{fixedPosition.type && <FixedPositionBox />}
<PlaybackTimeline />
{mapSwitch == "true" ? (
<>
+239
View File
@@ -0,0 +1,239 @@
import React, { FC, useRef, useState, 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,
savedRecording,
playbackIndex,
playbackPaused,
stopPlayback,
pausePlayback,
resumePlayback,
seekToSnapshot,
} = useTrainMenu();
const [trackWidth, setTrackWidth] = useState(1);
const trackWidthRef = useRef(1);
if (recorderState !== "playing" || !savedRecording) return null;
const total = savedRecording.snapshots.length;
const snap = savedRecording.snapshots[playbackIndex];
// スナップショット時刻 = 録画開始時刻 + elapsed
const snapTime = dayjs(savedRecording.recordedAt).add(snap.t, "ms");
const timeLabel = snapTime.format("HH:mm:ss");
// 録画の総時間をフォーマット
const totalSec = Math.round(savedRecording.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;
// スクラバーのドラッグ処理
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: (evt) => {
const x = evt.nativeEvent.locationX;
const idx = Math.round((x / trackWidthRef.current) * (total - 1));
seekToSnapshot(Math.max(0, Math.min(idx, total - 1)));
},
onPanResponderMove: (evt) => {
const x = evt.nativeEvent.locationX;
const idx = Math.round((x / trackWidthRef.current) * (total - 1));
seekToSnapshot(Math.max(0, Math.min(idx, total - 1)));
},
})
).current;
const onTrackLayout = useCallback((e: LayoutChangeEvent) => {
const w = e.nativeEvent.layout.width;
setTrackWidth(w);
trackWidthRef.current = w;
}, []);
const btnSize = moderateScale(36);
const iconSize = moderateScale(20);
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,
}),
};
+31 -3
View File
@@ -72,10 +72,18 @@ const initialState = {
recorderState: 'idle' as 'idle' | 'recording' | 'playing',
recordingSnapshotCount: 0,
savedRecording: null as TrainRecording | null,
/** 現在再生中のスナップショットインデックス */
playbackIndex: 0,
/** 再生一時停止中か */
playbackPaused: false,
startRecording: () => {},
stopRecording: (): Promise<void> => Promise.resolve(),
startPlayback: () => {},
stopPlayback: () => {},
pausePlayback: () => {},
resumePlayback: () => {},
/** 指定インデックスに直接ジャンプ(シーク) */
seekToSnapshot: (_index: number) => {},
addTrainSnapshot: (_trains: TrainEntry[]) => {},
deleteRecording: (): Promise<void> => Promise.resolve(),
};
@@ -165,6 +173,7 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
const [recordingSnapshotCount, setRecordingSnapshotCount] = useState(0);
const [savedRecording, setSavedRecording] = useState<TrainRecording | null>(null);
const [playbackIndex, setPlaybackIndex] = useState(0);
const [playbackPaused, setPlaybackPaused] = useState(false);
const recordingStartTimeRef = useRef<number>(0);
const recordingSnapshotsRef = useRef<Array<{ t: number; trains: TrainEntry[] }>>([]);
@@ -173,9 +182,10 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
loadRecording().then((r) => { if (r) setSavedRecording(r); });
}, []);
// 再生ループ: playbackIndex か recorderState が変わるたびに次のスナップショットへ
// 再生ループ: playbackIndex / recorderState / playbackPaused が変わるたびに次へ進める
useEffect(() => {
if (recorderState !== 'playing' || !savedRecording || savedRecording.snapshots.length === 0) return;
if (playbackPaused) return; // 一時停止中はタイマーを張らない
const snap = savedRecording.snapshots[playbackIndex];
setMockTrainPositions(snap.trains);
const nextIndex = (playbackIndex + 1) % savedRecording.snapshots.length;
@@ -184,7 +194,7 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
: Math.max(savedRecording.snapshots[nextIndex].t - snap.t, 3000);
const timer = setTimeout(() => setPlaybackIndex(nextIndex), delay);
return () => clearTimeout(timer);
}, [recorderState, playbackIndex, savedRecording]);
}, [recorderState, playbackIndex, savedRecording, playbackPaused]);
const startRecording = () => {
// 録画中はライブデータを取得するためモックをOFF
@@ -212,15 +222,28 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
const startPlayback = () => {
if (!savedRecording || savedRecording.snapshots.length === 0) return;
// 再生中はモックONにして録画データを流す
setMockApiFeatureEnabledState(true);
AS.setItem(STORAGE_KEYS.MOCK_API_FEATURE_ENABLED, 'true');
setPlaybackIndex(0);
setPlaybackPaused(false);
setRecorderState('playing');
};
const stopPlayback = () => {
setRecorderState('idle');
setPlaybackPaused(false);
};
const pausePlayback = () => setPlaybackPaused(true);
const resumePlayback = () => setPlaybackPaused(false);
const seekToSnapshot = (index: number) => {
if (!savedRecording) return;
const i = Math.max(0, Math.min(index, savedRecording.snapshots.length - 1));
setPlaybackIndex(i);
// シーク時は一時停止して、その画面を表示する
setPlaybackPaused(true);
setMockTrainPositions(savedRecording.snapshots[i].trains);
};
// useCurrentTrain から呼ばれる: ライブfetch成功時にスナップショットを追記
@@ -350,10 +373,15 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
recorderState,
recordingSnapshotCount,
savedRecording,
playbackIndex,
playbackPaused,
startRecording,
stopRecording,
startPlayback,
stopPlayback,
pausePlayback,
resumePlayback,
seekToSnapshot,
addTrainSnapshot,
deleteRecording,
}}