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