Files
streamyfin/components/chapters/ChapterTicks.tsx
Gauvain e3f4eea132 fix(chapters): address review findings + trickplay polish
Copilot + CodeRabbit review findings:
- React.memo ChapterTicks and ChapterList (project guideline: hot-path
  components must use React.memo to cut redraw work during control
  updates).
- chapterNameAt now sorts the chapter array once instead of twice per
  call. The previous version went through currentChapterIndex
  (chapterStartsMs + sort) then sortedChapters (sort again). Runs on
  every playback tick, so the duplicate work added up.
- Import getUserLibraryApi from the public barrel
  (@jellyfin/sdk/lib/utils/api) instead of the deep internal path
  (@jellyfin/sdk/lib/utils/api/user-library-api) to match the rest of
  the codebase and avoid coupling to SDK file layout.

TrickplayBubble polish:
- Sit just above the slider (bottom: 0) so the bubble no longer overlaps
  the progress bar.
- Move the chapter-name + timestamp overlay to the bottom-left of the
  preview frame, smaller font, in front of the surrounding overlays
  (zIndex + elevation).

BottomControls cleanup:
- Drop dev-only "pick one to test" comment in favour of a one-line note
  on TICK_HEIGHT.
- Inline scrubMs into its useMemo callback so the scrub-chapter-name
  lookup only recomputes while a slide is active.
2026-05-27 20:08:21 +02:00

88 lines
3.0 KiB
TypeScript

/**
* Chapter tick marks drawn as an absolute overlay over a progress slider.
* Renders nothing for media with one or zero chapters. `pointerEvents: "none"`
* so the slider underneath still receives touches.
*/
import { memo, useState } from "react";
import { type LayoutChangeEvent, PixelRatio, View } from "react-native";
import type { ChapterMarker } from "@/utils/chapters";
interface ChapterTicksProps {
/** Pre-computed markers (caller memoizes — avoids double-computing here). */
markers: ChapterMarker[];
/** Tick colour. */
color?: string;
/** Tick height in px — slightly less than the slider track thickness. */
height?: number;
/** Tick width in px — integer to avoid sub-pixel anti-aliasing. */
width?: number;
}
function ChapterTicksComponent({
markers,
// Semi-transparent black contrasts against both the filled progress
// (#fff) and the unfilled track (rgba(255,255,255,0.2)) so the ticks
// stay visible across the whole bar as playback advances.
color = "rgba(0,0,0,0.55)",
height = 14,
width = 2,
}: ChapterTicksProps) {
// Hooks must run unconditionally — keep them before any early return.
const [sliderWidth, setSliderWidth] = useState(0);
const handleLayout = (e: LayoutChangeEvent) => {
setSliderWidth(e.nativeEvent.layout.width);
};
// One chapter (typically a single marker at 0) is not worth marking.
if (markers.length <= 1) return null;
return (
<View
pointerEvents='none'
onLayout={handleLayout}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
bottom: 0,
// Let ticks taller than this container bleed beyond its bounds.
overflow: "visible",
}}
>
{sliderWidth > 0 &&
markers
// Skip the leading 0ms marker — it overlaps the slider start and
// adds visual noise at an already-rendered boundary.
.filter((marker) => marker.positionMs > 0)
.map((marker, index) => {
// Align both the position AND the width onto the device's
// physical pixel grid. Without this, fractional dp values land
// at different sub-pixel fractions per tick — Android samples
// each one differently and some ticks render visibly thicker.
const centerDp = (marker.percent / 100) * sliderWidth;
const left = PixelRatio.roundToNearestPixel(centerDp - width / 2);
const snappedWidth = PixelRatio.roundToNearestPixel(width);
return (
<View
key={`${marker.positionMs}-${index}`}
style={{
position: "absolute",
left,
top: "50%",
marginTop: -height / 2,
height,
width: snappedWidth,
backgroundColor: color,
}}
/>
);
})}
</View>
);
}
export const ChapterTicks = memo(ChapterTicksComponent);