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:
co-authored by
Copilot
parent
597151e875
commit
43dcaab643
@@ -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" ? (
|
||||
<>
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user