Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions src/features/recorder/playhead-cursor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,15 @@ import { Accent } from '@/constants/theme';
import type { Segment } from '@/db/schema';
import { segmentOffsets } from '@/utils/segment-window';
import { msToPx, pxToMs } from './track-mapping';
import { KNOB, POP_LANE, SCRUB_INSET, STEP, THUMB_HEIGHT, THUMB_WIDTH } from './track-metrics';
import {
BADGE_SIZE,
KNOB,
POP_LANE,
SCRUB_INSET,
STEP,
THUMB_HEIGHT,
THUMB_WIDTH,
} from './track-metrics';

/** Max rate at which a knob drag issues player seeks (the knob itself moves every frame). */
const SCRUB_INTERVAL_MS = 80;
Expand Down Expand Up @@ -267,14 +275,16 @@ const styles = StyleSheet.create({
},
cursorLine: {
width: 2,
// Runs the thumb's full height plus a short tail below it, so the knob riding the line's
// end hangs clear of the thumbnail (into SCRUB_LANE) instead of overlapping its bottom.
height: THUMB_HEIGHT + 5,
// Runs from just below the ordinal pill's dip into the thumb (the cursor is a sibling
// drawn OVER the ScrollView, so a line starting at the thumb's top edge struck through
// the numbers) down to a short tail below the thumb, so the knob riding the line's end
// hangs clear of the thumbnail (into SCRUB_LANE) instead of overlapping its bottom.
height: THUMB_HEIGHT + 5 - BADGE_SIZE / 2,
borderRadius: 1,
backgroundColor: '#fff',
// The thumbs sit POP_LANE below the scroll-frame top (the badge-pill lane); match it so
// the line starts on the thumb's top edge.
marginTop: POP_LANE,
// The thumbs sit POP_LANE below the scroll-frame top (the badge-pill lane); the pill
// then dips BADGE_SIZE/2 into the thumb — start the line under both.
marginTop: POP_LANE + BADGE_SIZE / 2,
},
cursorKnob: {
width: KNOB,
Expand Down
42 changes: 30 additions & 12 deletions src/features/recorder/segment-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ import Animated, {
} from 'react-native-reanimated';
import Sortable from 'react-native-sortables';

import { GlassPill } from '@/components/glass-pill';
import { Accent, Spacing } from '@/constants/theme';
import type { Segment } from '@/db/schema';
import { useThumbnail } from '@/hooks/use-thumbnail';
import { formatDurationPadded } from '@/utils/format';
import { effMs } from '@/utils/segment-window';
import { PlayheadCursor, type Cursor } from './playhead-cursor';
import {
BADGE_SIZE,
POP_LANE,
RECORD_BAR_GAP,
RECORD_BUTTON_SIZE,
Expand All @@ -39,10 +41,6 @@ import {
const TRASH_SIZE = 56;
// Nudge the trash below the record button's exact center so it clears the preview modal.
const TRASH_DROP_OFFSET = 18;
// Badge pill diameter — it doubles as the drag handle's visible affordance, so it's sized
// generously. Half of it rides above the thumb's top edge; POP_LANE (the vertical breathing
// room inside the scroll frame) must be at least BADGE_SIZE / 2.
const BADGE_SIZE = 18;

type Props = {
segments: Segment[];
Expand Down Expand Up @@ -132,6 +130,10 @@ function Bar({
// as it's dragged up to the trash. Enabled by default.
<Sortable.PortalProvider>
<View style={styles.bar}>
{/* Glass surface as a passive background LAYER, not a container — the Sortable grid,
ScrollView, and playhead keep their exact hierarchy (and gesture/portal behavior)
above it. pointerEvents="none" so it can never intercept a touch. */}
<GlassPill style={styles.barSurface} pointerEvents="none" />
Comment thread
morepriyam marked this conversation as resolved.
{/* Trash drop target — above the bar, fades in during a drag. pointerEvents="none" so it
never intercepts touches; it's purely a drop zone hit-tested from the drag position. */}
<View style={styles.trashWrap} pointerEvents="none">
Expand All @@ -141,7 +143,7 @@ function Bar({
</View>

<View
style={[styles.viewport, cursor && styles.viewportScrub]}
style={styles.viewport}
onLayout={(e) => {
viewportW.value = e.nativeEvent.layout.width;
}}>
Expand Down Expand Up @@ -326,9 +328,23 @@ const styles = StyleSheet.create({
gap: Spacing.two,
marginHorizontal: Spacing.three,
paddingHorizontal: Spacing.two,
paddingVertical: Spacing.two,
// The viewport carries the SCRUB_LANE below the thumbs; mirroring the whole lane as
// top padding (with no extra base padding — POP_LANE already provides breathing room)
// keeps the thumbs dead-center in the slimmest symmetric bar.
paddingTop: SCRUB_LANE,
paddingBottom: 0,
borderRadius: Spacing.three,
backgroundColor: 'rgba(0,0,0,0.4)',
},
// The bar's glass background (dark-scrim fallback via GlassPill) — fills the bar behind
// its content and carries the rounding.
barSurface: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: Spacing.three,
overflow: 'hidden',
},
trashWrap: {
position: 'absolute',
Expand All @@ -351,11 +367,10 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
viewport: { flex: 1, overflow: 'hidden' },
// Lane below the thumbs the playhead knob hangs into — only needed while previewing (when a
// cursor is present). In record mode it would just add dead space below the bar and push the
// thumbs above the export button's centerline, so it's applied conditionally.
viewportScrub: { paddingBottom: SCRUB_LANE },
// The scrub lane (the strip below the thumbs the playhead knob hangs into) is reserved
// permanently, not just while previewing — adding it only with the cursor grew the bar
// and visibly bumped it upward every time a preview opened.
viewport: { flex: 1, overflow: 'hidden', paddingBottom: SCRUB_LANE },
content: {
alignItems: 'center',
paddingLeft: SCRUB_INSET,
Expand Down Expand Up @@ -453,5 +468,8 @@ const styles = StyleSheet.create({
backgroundColor: Accent,
alignItems: 'center',
justifyContent: 'center',
// The viewport reserves SCRUB_LANE below the thumbs, floating them above the bar's
// centerline — match it so the button's center stays on the thumbs' center.
marginBottom: SCRUB_LANE,
},
});
12 changes: 10 additions & 2 deletions src/features/recorder/track-metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,17 @@ export const RECORD_BUTTON_SIZE = 76;
export const RECORD_BAR_GAP = Spacing.three;
/** Horizontal rhythm of the track: one thumb + the grid's column gap. */
export const STEP = THUMB_WIDTH + TRACK_GAP;
/** Extra lane below the thumbs the cursor knob hangs into (keeps it off taps/✕/drag). */
export const SCRUB_LANE = 16;
/** Extra lane below the thumbs the cursor knob hangs into (keeps it off taps/✕/drag). The
* knob visibly overhangs the scroll frame by ~4pt; the rest is finger headroom for its
* hitSlop. Also the bar's top padding (see segment-bar): the lane is bottom-only, so the
* bar mirrors it on top to keep the thumbs visually centered. */
export const SCRUB_LANE = 12;
export const KNOB = 14;
/** Ordinal badge-pill diameter — it doubles as the drag handle's visible affordance, so it's
* sized generously. Half of it rides above the thumb's top edge; POP_LANE (the vertical
* breathing room inside the scroll frame) must be at least BADGE_SIZE / 2. Shared with the
* playhead cursor so its line can start below the pill instead of striking through it. */
export const BADGE_SIZE = 18;
/** Left inset of the track content so the playhead knob at globalMs=0 (centered on the line at
* the first thumb's left edge) isn't clipped by the viewport's overflow:hidden. The cursor adds
* the same inset to its x so the line stays aligned with the thumbnails. */
Expand Down
66 changes: 59 additions & 7 deletions src/features/recorder/use-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,12 @@ function resolveActiveIndex(

/**
* In-recorder preview state: drives one `expo-video` player across a draft's segments
* (sequential playback of each clip's effective file — edited if present, else original),
* tracks the active clip, the source playhead, and a draft-global playhead for the bar cursor.
* (each clip's effective file — edited if present, else original), tracks the active clip,
* the source playhead, and a draft-global playhead for the bar cursor.
*
* Playback scope: a tapped segment plays only itself and parks at its end ('single');
* the ▶ / surface tap plays through from the playhead to the draft's end, as do bar
* scrubs ('all').
*
* `anchorId` is the tapped segment that opened the preview — `null` means preview closed;
* the hook stays mounted with a stopped, unloaded player.
Expand Down Expand Up @@ -72,6 +76,11 @@ export function usePreview(segments: Segment[], anchorId: string | null) {
const swapInFlightRef = useRef(false);
// Whether the next readyToPlay should start playback (set when auto-advancing).
const wantPlayRef = useRef(false);
// Playback scope: 'single' parks at the current clip's end and never advances (a tapped
// segment plays only itself); 'all' walks clip to clip through the draft (the ▶ / surface
// tap, and scrubs — resuming after either plays through rather than dying at the next
// boundary). Held in a ref: playback events read it, nothing renders from it.
const scopeRef = useRef<'single' | 'all'>('single');
// The file the player has been given (set at load *initiation* so decisions made while a
// load is still in flight compare against the incoming file, not the outgoing one).
const loadedFileRef = useRef<string | null>(null);
Expand All @@ -85,7 +94,11 @@ export function usePreview(segments: Segment[], anchorId: string | null) {
// on the opening commit. Tapping another thumb mid-preview (selectSegment) arms the
// same intent itself — a tap always means "play this clip".
useEffect(() => {
if (anchorId != null) wantPlayRef.current = true;
if (anchorId != null) {
wantPlayRef.current = true;
// A tap-open plays just the tapped clip; the ▶ / surface tap widens the scope.
scopeRef.current = 'single';
}
}, [anchorId]);

// Playback and the cursor act on this row.
Expand Down Expand Up @@ -223,14 +236,28 @@ export function usePreview(segments: Segment[], anchorId: string | null) {
setPositionMs(Math.round(currentTime * 1000));
});

// Clip ran out — move on (or park at the draft's end).
// Clip ran out — in 'single' scope park at ITS end; otherwise move on (or park at the
// draft's end). Parking mirrors advance()'s end-of-draft branch (pause AT the out-point,
// togglePlay's end check handles continuing), but lands 1ms shy of it: at exactly outMs
// the draft-global position IS the boundary, which indexAtGlobalMs/msToPx resolve to the
// NEXT segment at fraction 0 — the bar knob visibly hopped onto the next thumb's left
// edge. 1ms back keeps the knob (and any late timeUpdate) inside the played clip, and is
// still deep within END_EPSILON_MS so ▶ treats it as "at the end".
useEventListener(player, 'playToEnd', () => {
if (!active || swapInFlightRef.current || advancingRef.current) return;
if (scopeRef.current === 'single') {
const parkMs = Math.max(inMs(active), outMs(active) - 1);
player.pause();
player.currentTime = parkMs / 1000;
setPositionMs(parkMs);
return;
}
advance();
});

/** Make a clip the active one (thumb tap while previewing); plays from its in-point —
* same as tapping a thumb from the recorder, so a tap ALWAYS means "play this clip". */
* same as tapping a thumb from the recorder, so a tap ALWAYS means "play this clip",
* and ONLY this clip: a tap narrows the scope to 'single' (#166). */
const selectSegment = useCallback(
(id: string) => {
// Stale tap — the row vanished (e.g. deleted) between render and gesture delivery.
Expand All @@ -239,6 +266,7 @@ export function usePreview(segments: Segment[], anchorId: string | null) {
// unexpectedly starting playback of a clip the user never tapped.
const target = segments.find((s) => s.id === id);
if (!target) return;
scopeRef.current = 'single';
pendingSeekRef.current = null;
// Pausing also silences the outgoing clip's event stream during the swap.
player.pause();
Expand Down Expand Up @@ -280,15 +308,36 @@ export function usePreview(segments: Segment[], anchorId: string | null) {
player.pause();
return;
}
// The ▶ / surface tap always means "play THROUGH from here" (#165) — it widens the
// scope so playback continues clip to clip even after a single-clip (thumb tap) play.
scopeRef.current = 'all';
if (active) {
// Read the live position imperatively — keeping positionMs out of the deps keeps
// this callback's identity stable across the 4Hz playhead updates.
const ms = Math.round(player.currentTime * 1000);
if (ms >= outMs(active) - END_EPSILON_MS) player.currentTime = inMs(active) / 1000;
if (ms >= outMs(active) - END_EPSILON_MS) {
// Parked at this clip's end: continue into the next playable clip, or — at the
// draft's end — wrap to the first (a play tap at the very end means "replay").
const next =
segments.slice(activeIndex + 1).find((s) => effMs(s) > 0) ??
segments.find((s) => effMs(s) > 0);
if (next && next.id !== activeId) {
wantPlayRef.current = true;
pendingSeekRef.current = null;
// Land the position on the incoming clip's in-point in the same render as the
// selection (same stale-position jump as advance() otherwise).
setPositionMs(inMs(next));
setSelectedId(next.id);
return;
}
// This is the only playable clip — restart it in place.
player.currentTime = inMs(active) / 1000;
setPositionMs(inMs(active));
}
}
advancingRef.current = false;
player.play();
}, [player, active]);
}, [player, active, activeId, activeIndex, segments]);

/** Seek to a draft-global offset (bar-cursor scrub); swaps the loaded clip when crossed. */
const seekToGlobalMs = useCallback(
Expand All @@ -297,6 +346,9 @@ export function usePreview(segments: Segment[], anchorId: string | null) {
if (player.playing) player.pause();
// Scrubbing always lands paused — even when it interrupts an in-flight auto-advance.
wantPlayRef.current = false;
// Scrubbing engages the draft-global timeline, so resuming plays THROUGH — stopping
// dead at the next clip boundary after a scrub would read as broken.
scopeRef.current = 'all';
const clamped = clamp(g, 0, totalMs - 1);
const i = indexAtGlobalMs(segments, offsets, clamped);
if (i < 0) return;
Expand Down