From 0788d0d523909e50abc086e426d23d8575e7a53a Mon Sep 17 00:00:00 2001 From: morepriyam Date: Sat, 15 Aug 2026 01:33:11 +0530 Subject: [PATCH 1/2] =?UTF-8?q?feat(recorder):=20playback=20scopes=20?= =?UTF-8?q?=E2=80=94=20tap=20plays=20one=20clip,=20=E2=96=B6=20plays=20thr?= =?UTF-8?q?ough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tapped segment now plays only itself and parks at its end instead of auto-advancing through every following clip. The center ▶ / surface tap becomes the play-through control: it resumes from wherever the playhead is and walks clip to clip to the draft's end — continuing into the next clip when parked at a clip's end, and wrapping to the first clip when parked at the draft's very end. - new playback scope ref ('single' | 'all'): tap-open and thumb taps narrow to 'single'; togglePlay and bar scrubs widen to 'all' - playToEnd parks 1ms shy of the out-point in 'single' scope so the bar knob rests inside the played clip — at exactly outMs the global position is the boundary, which msToPx resolves to the NEXT thumb's left edge - clip boundaries still advance only on the native playToEnd event, so no path truncates playback Closes #166, closes #165 --- src/features/recorder/use-preview.ts | 66 +++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 7 deletions(-) diff --git a/src/features/recorder/use-preview.ts b/src/features/recorder/use-preview.ts index 3826146..47d7964 100644 --- a/src/features/recorder/use-preview.ts +++ b/src/features/recorder/use-preview.ts @@ -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. @@ -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(null); @@ -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. @@ -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. @@ -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(); @@ -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( @@ -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; From 12cad305aaa62b438f3ca9db0e795b80b02d1905 Mon Sep 17 00:00:00 2001 From: morepriyam Date: Sat, 15 Aug 2026 01:56:35 +0530 Subject: [PATCH 2/2] polish(recorder): segment bar glass, stable size, centered thumbs, clean ordinals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preview-adjacent polish that fell out of exercising the playback scopes: - glass segment bar: GlassPill as a passive absolute-fill background LAYER (pointerEvents none) behind the existing hierarchy — the Sortable grid, ScrollView, playhead, and drag-to-trash portal are untouched, so no gesture or reorder behavior can change - stable bar size: the scrub lane is reserved permanently instead of added with the cursor, which grew the bar and bumped it upward on every preview open; the → button gets a matching margin to stay on the thumbs' centerline - centered + slimmer: the lane is mirrored as top padding (thumbs land dead-center) and trimmed 16→12 — the knob only overhangs ~4pt, the rest is hitSlop headroom - playhead line no longer strikes through the ordinal pills: the cursor is a sibling drawn OVER the ScrollView (zIndex can't cross that boundary), so the line now starts below the pill's dip into the thumb; BADGE_SIZE moves into track-metrics so bar and cursor share it --- src/features/recorder/playhead-cursor.tsx | 24 +++++++++---- src/features/recorder/segment-bar.tsx | 42 ++++++++++++++++------- src/features/recorder/track-metrics.ts | 12 +++++-- 3 files changed, 57 insertions(+), 21 deletions(-) diff --git a/src/features/recorder/playhead-cursor.tsx b/src/features/recorder/playhead-cursor.tsx index fafa4d9..26e6481 100644 --- a/src/features/recorder/playhead-cursor.tsx +++ b/src/features/recorder/playhead-cursor.tsx @@ -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; @@ -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, diff --git a/src/features/recorder/segment-bar.tsx b/src/features/recorder/segment-bar.tsx index 20ca256..b619873 100644 --- a/src/features/recorder/segment-bar.tsx +++ b/src/features/recorder/segment-bar.tsx @@ -16,6 +16,7 @@ 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'; @@ -23,6 +24,7 @@ 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, @@ -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[]; @@ -132,6 +130,10 @@ function Bar({ // as it's dragged up to the trash. Enabled by default. + {/* 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. */} + {/* 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. */} @@ -141,7 +143,7 @@ function Bar({ { viewportW.value = e.nativeEvent.layout.width; }}> @@ -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', @@ -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, @@ -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, }, }); diff --git a/src/features/recorder/track-metrics.ts b/src/features/recorder/track-metrics.ts index 2efb583..aac68b8 100644 --- a/src/features/recorder/track-metrics.ts +++ b/src/features/recorder/track-metrics.ts @@ -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. */