diff --git a/components/Apps.tsx b/components/Apps.tsx
index 02fcbe6..867545a 100644
--- a/components/Apps.tsx
+++ b/components/Apps.tsx
@@ -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 && }
+
{mapSwitch == "true" ? (
<>
diff --git a/components/Apps/PlaybackTimeline.tsx b/components/Apps/PlaybackTimeline.tsx
new file mode 100644
index 0000000..357d21c
--- /dev/null
+++ b/components/Apps/PlaybackTimeline.tsx
@@ -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 (
+
+ {/* 上段: ボタン + 時刻 + コマ数 */}
+
+ {/* 先頭コマへ */}
+ seekToSnapshot(0)}
+ style={[styles.btn(btnSize, colors.borderSecondary)]}
+ hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
+ >
+
+
+
+ {/* 前のコマ */}
+ seekToSnapshot(playbackIndex - 1)}
+ style={[styles.btn(btnSize, colors.borderSecondary)]}
+ hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
+ >
+
+
+
+ {/* 再生 / 一時停止 */}
+
+
+
+
+ {/* 次のコマ */}
+ seekToSnapshot(playbackIndex + 1)}
+ style={[styles.btn(btnSize, colors.borderSecondary)]}
+ hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
+ >
+
+
+
+ {/* 末尾コマへ */}
+ seekToSnapshot(total - 1)}
+ style={[styles.btn(btnSize, colors.borderSecondary)]}
+ hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
+ >
+
+
+
+ {/* スペーサー */}
+
+
+ {/* 時刻表示 */}
+
+
+ {timeLabel}
+
+
+ {playbackIndex + 1}/{total} {totalLabel}
+
+
+
+ {/* 停止ボタン */}
+
+
+
+
+
+ {/* 下段: スクラバートラック */}
+
+ {/* トラック背景 */}
+
+ {/* 進捗バー */}
+
+
+ {/* ドラッグハンドル */}
+
+
+
+ );
+};
+
+// ヘルパー: ボタンスタイル生成
+const styles = {
+ btn: (size: number, bg: string) => ({
+ width: size,
+ height: size,
+ borderRadius: size / 2,
+ backgroundColor: bg,
+ alignItems: "center" as const,
+ justifyContent: "center" as const,
+ }),
+};
diff --git a/stateBox/useTrainMenu.tsx b/stateBox/useTrainMenu.tsx
index b5f4fae..14526b9 100644
--- a/stateBox/useTrainMenu.tsx
+++ b/stateBox/useTrainMenu.tsx
@@ -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 => Promise.resolve(),
startPlayback: () => {},
stopPlayback: () => {},
+ pausePlayback: () => {},
+ resumePlayback: () => {},
+ /** 指定インデックスに直接ジャンプ(シーク) */
+ seekToSnapshot: (_index: number) => {},
addTrainSnapshot: (_trains: TrainEntry[]) => {},
deleteRecording: (): Promise => Promise.resolve(),
};
@@ -165,6 +173,7 @@ export const TrainMenuProvider: FC = ({ children }) => {
const [recordingSnapshotCount, setRecordingSnapshotCount] = useState(0);
const [savedRecording, setSavedRecording] = useState(null);
const [playbackIndex, setPlaybackIndex] = useState(0);
+ const [playbackPaused, setPlaybackPaused] = useState(false);
const recordingStartTimeRef = useRef(0);
const recordingSnapshotsRef = useRef>([]);
@@ -173,9 +182,10 @@ export const TrainMenuProvider: FC = ({ 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 = ({ 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 = ({ 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 = ({ children }) => {
recorderState,
recordingSnapshotCount,
savedRecording,
+ playbackIndex,
+ playbackPaused,
startRecording,
stopRecording,
startPlayback,
stopPlayback,
+ pausePlayback,
+ resumePlayback,
+ seekToSnapshot,
addTrainSnapshot,
deleteRecording,
}}