Skip to content

Tests + subtitle enhancements#16

Open
leganz wants to merge 4 commits into
mainfrom
feature/tests
Open

Tests + subtitle enhancements#16
leganz wants to merge 4 commits into
mainfrom
feature/tests

Conversation

@leganz
Copy link
Copy Markdown
Contributor

@leganz leganz commented Apr 10, 2026

No description provided.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 10, 2026

Walkthrough

Adds many new UI components, hooks, contexts, tests, and Storybook stories across the player codebase. Notable additions: PlayerErrorBoundary; GestureOverlay and gesture wiring in VideoPlayer; SubtitleDisplay and subtitle-related controls/hooks; PauseAd, RewardedAdOverlay, and ad-related controls; Equalizer, SleepTimer, ShareButton, SubtitleSettings, and related hooks (useABLoop, useEqualizer, useAutoplayDetection, useFocusTrap, etc.); ThumbnailPreview and ProgressBar feature extensions; FairuProvider composition; TrackingProvider refactor to use TrackingService; extensive Vitest + RTL test suites; story/demo updates; and package.json bundle-size CI config and devDependencies.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/tests

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 19

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (23)
src/components/VideoPlayer/LogoOverlay/LogoOverlay.test.tsx-192-200 (1)

192-200: ⚠️ Potential issue | 🟡 Minor

Assert default prevention explicitly for the link-click test.

On Line 192, the test name says default is prevented, but the body only asserts onClick was called.

✅ Suggested test fix
   it('calls onClick and prevents default when both href and onClick are set', () => {
     const onClick = vi.fn();
     const { container } = render(
       <LogoOverlay config={createLogoConfig({ href: 'https://example.com', onClick })} />
     );
     const link = container.querySelector('a')!;
-    fireEvent.click(link);
+    const defaultNotPrevented = fireEvent.click(link);
     expect(onClick).toHaveBeenCalledTimes(1);
+    expect(defaultNotPrevented).toBe(false);
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/VideoPlayer/LogoOverlay/LogoOverlay.test.tsx` around lines 192
- 200, Update the test in LogoOverlay.test.tsx that uses LogoOverlay and
createLogoConfig so it actually verifies default prevention: either spy on
Event.prototype.preventDefault before calling fireEvent.click(link) (e.g.,
vi.spyOn(Event.prototype, 'preventDefault') and assert it was called) or have
the onClick mock call e.preventDefault() and then assert the received event has
defaultPrevented true (inspect onClick.mock.calls[0][0].defaultPrevented);
ensure the test still asserts onClick was called and add the explicit
expectation that default prevention occurred.
src/components/markers/MarkerList/MarkerList.test.tsx-98-117 (1)

98-117: ⚠️ Potential issue | 🟡 Minor

Assert single invocation in click-handler tests.

At Line 102, Line 109, and Line 116 you only check arguments; these tests still pass if onMarkerClick fires multiple times per click. Add toHaveBeenCalledTimes(1) to catch duplicate-callback regressions.

Proposed test hardening
 it('calls onMarkerClick with marker and index when clicked', () => {
   const onMarkerClick = vi.fn();
   renderMarkerList({ onMarkerClick });
   fireEvent.click(screen.getByText('Highlight 2'));
+  expect(onMarkerClick).toHaveBeenCalledTimes(1);
   expect(onMarkerClick).toHaveBeenCalledWith(markers[1], 1);
 });

 it('calls onMarkerClick for first marker', () => {
   const onMarkerClick = vi.fn();
   renderMarkerList({ onMarkerClick });
   fireEvent.click(screen.getByText('Highlight 1'));
+  expect(onMarkerClick).toHaveBeenCalledTimes(1);
   expect(onMarkerClick).toHaveBeenCalledWith(markers[0], 0);
 });

 it('calls onMarkerClick for last marker', () => {
   const onMarkerClick = vi.fn();
   renderMarkerList({ onMarkerClick });
   fireEvent.click(screen.getByText('Highlight 3'));
+  expect(onMarkerClick).toHaveBeenCalledTimes(1);
   expect(onMarkerClick).toHaveBeenCalledWith(markers[2], 2);
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/markers/MarkerList/MarkerList.test.tsx` around lines 98 - 117,
The click-handler tests for MarkerList (tests named "calls onMarkerClick with
marker and index when clicked", "calls onMarkerClick for first marker", and
"calls onMarkerClick for last marker") only assert the arguments passed to the
mock onMarkerClick and not the invocation count; update each test that calls
renderMarkerList({ onMarkerClick }) and fireEvent.click(screen.getByText(...))
to also assert onMarkerClick has been called exactly once by adding
expect(onMarkerClick).toHaveBeenCalledTimes(1) after the existing
toHaveBeenCalledWith checks to prevent regressions where the handler might be
invoked multiple times.
src/components/a11y/ScreenReaderAnnouncer.tsx-21-23 (1)

21-23: ⚠️ Potential issue | 🟡 Minor

Clear stale announcement when message becomes empty.

Line [22] returns early for empty messages, so previous announcement text can remain rendered.

💡 Proposed fix
   useEffect(() => {
-    if (!message) return;
+    if (!message) {
+      setAnnouncement('');
+      return;
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/a11y/ScreenReaderAnnouncer.tsx` around lines 21 - 23, The
useEffect currently returns early when message is falsy, leaving the previous
announcement rendered; update the effect in ScreenReaderAnnouncer so it
explicitly clears the rendered announcement when message becomes empty (e.g., in
the useEffect watching message, handle the falsy branch by calling the
component's state setter or clearing the announcementRef/textContent — reference
useEffect, message, and whichever state/ref you use such as setAnnouncement or
announcementRef — so the aria-live node is cleared when message is empty).
src/components/VideoPlayer/EndScreen/EndScreen.test.tsx-283-294 (1)

283-294: ⚠️ Potential issue | 🟡 Minor

Use a countdown-specific assertion after cancel.

"Up next" is shared UI copy here, so its absence doesn’t uniquely prove the countdown was removed. Assert on the countdown control/root instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/VideoPlayer/EndScreen/EndScreen.test.tsx` around lines 283 -
294, The test currently asserts the absence of the shared "Up next" copy which
is ambiguous; after clicking Cancel in the EndScreen render (created via
createEndScreenConfig and rendered by EndScreen), replace the final assertion so
it specifically targets the autoplay countdown element/root (e.g., use
screen.queryByRole('timer') or screen.queryByTestId('autoplay-countdown' or
'countdown-root') depending on what the component exposes) and assert that that
countdown node is not in the document instead of checking for "Up next".
src/components/VideoPlayer/EndScreen/EndScreen.test.tsx-2-2 (1)

2-2: ⚠️ Potential issue | 🟡 Minor

Drop the unused waitFor import.

It isn’t used anywhere in this file, and it’s already failing the test job.

🧹 Suggested cleanup
-import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
+import { render, screen, fireEvent, act } from '@testing-library/react';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/VideoPlayer/EndScreen/EndScreen.test.tsx` at line 2, The
import list in the test file includes an unused symbol `waitFor`; remove
`waitFor` from the import statement that currently imports from
'@testing-library/react' (the line importing render, screen, fireEvent, act,
waitFor) so the file only imports the utilities actually used (render, screen,
fireEvent, act) to resolve the failing test job due to the unused import.
src/components/VideoPlayer/EndScreen/EndScreen.test.tsx-110-137 (1)

110-137: ⚠️ Potential issue | 🟡 Minor

Set showReplay: true explicitly in the replay tests.

Right now these cases depend on the helper default, so they can still pass if the showReplay gate is accidentally ignored.

✅ Suggested change
       <EndScreen
-        config={createEndScreenConfig()}
+        config={createEndScreenConfig({ showReplay: true })}
         currentTime={300}
         duration={300}
         isEnded={true}
         onReplay={onReplay}
       />
@@
       <EndScreen
-        config={createEndScreenConfig()}
+        config={createEndScreenConfig({ showReplay: true })}
         currentTime={300}
         duration={300}
         isEnded={true}
         onReplay={onReplay}
       />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/VideoPlayer/EndScreen/EndScreen.test.tsx` around lines 110 -
137, The replay tests rely on the helper's default; explicitly set showReplay:
true in the config passed to EndScreen in both test cases to ensure the replay
gate is tested: update the render calls in the "renders replay button when
showReplay is true and onReplay is provided" and "calls onReplay when replay
button is clicked" tests to pass createEndScreenConfig({ showReplay: true }) (or
otherwise set showReplay: true on the config object) so the component's
showReplay guard is actually exercised when rendering and when clicking the
Replay button.
src/components/controls/Equalizer/Equalizer.tsx-54-69 (1)

54-69: ⚠️ Potential issue | 🟡 Minor

Add semantic attributes to the toggle switch for better accessibility.

The toggle button should include role="switch" and aria-checked to properly communicate its state to assistive technologies.

♿ Proposed fix for accessibility
         <button
           type="button"
           onClick={() => onEnabledChange(!enabled)}
+          role="switch"
+          aria-checked={enabled}
           className={cn(
             'relative w-9 h-5 rounded-full transition-colors duration-200',
             enabled ? 'bg-[var(--fp-color-accent)]' : 'bg-[var(--fp-progress-bg)]'
           )}
           aria-label={enabled ? 'Disable equalizer' : 'Enable equalizer'}
         >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/controls/Equalizer/Equalizer.tsx` around lines 54 - 69, The
toggle button in the Equalizer component lacks semantic switch attributes;
update the button element (the one using onEnabledChange and the enabled prop)
to include role="switch" and aria-checked={enabled} so assistive tech can detect
its state (keep the existing aria-label and onClick behavior intact).
src/hooks/useABLoop.ts-81-96 (1)

81-96: ⚠️ Potential issue | 🟡 Minor

Very short loops only run once.

After seeking back to A, the guard only resets when currentTime < loopEnd - 0.15. If loopStart is within 150 ms of loopEnd, that condition is impossible after the seek, so the loop never re-arms. Re-arm when playback lands back near the seek target instead of comparing against loopEnd - 0.15.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useABLoop.ts` around lines 81 - 96, The justSeekedRef guard
currently waits for currentTime < loopEnd - 0.15 to clear (inside the useEffect
handling loop re-arming), which fails when loopStart is within 150ms of loopEnd;
change the re-arm condition to detect when playback has returned near the seek
target instead (e.g., check currentTime is close to loopStart within a small
tolerance like 0.15s) so justSeekedRef.current is set to false when currentTime
<= loopStart + tolerance (or otherwise within tolerance of loopStart) and keep
the rest of the logic (onSeek(loopStart), currentTime >= loopEnd) intact.
src/components/controls/ShareButton/ShareButton.stories.tsx-47-58 (1)

47-58: ⚠️ Potential issue | 🟡 Minor

Don't report clipboard failures as success.

The interactive story returns true even when writeText() throws or the Clipboard API is unavailable. That hides the failure path and documents the wrong contract for copyShareUrl.

💡 Suggested change
     const copyShareUrl = async (time?: number) => {
       const url = getShareUrl(time);
       if (typeof navigator !== 'undefined' && navigator.clipboard) {
         try {
           await navigator.clipboard.writeText(url);
           return true;
         } catch {
-          return true;
+          return false;
         }
       }
-      return true;
+      return false;
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/controls/ShareButton/ShareButton.stories.tsx` around lines 47
- 58, The helper copyShareUrl currently always returns true even when the
Clipboard API is missing or navigator.clipboard.writeText() throws; update
copyShareUrl to return true only on a successful write and return false (or
propagate a rejection) when clipboard is unavailable or writeText fails. Locate
the copyShareUrl function (and its call to getShareUrl) and modify the try/catch
and the navigator.clipboard availability check so that failures return false (or
rethrow) instead of returning true, ensuring the promise resolves to false on
failure.
src/hooks/useABLoop.ts-46-72 (1)

46-72: ⚠️ Potential issue | 🟡 Minor

Avoid calling state setters inside updater functions.

Lines 53 and 67 call state setters (setLoopStart and setLoopEnd) from within updater functions. Updater functions should be pure—React invokes them twice in Strict Mode to detect impurities, so these nested setter calls execute twice per state update, potentially enqueueing duplicate swaps. Instead, compute both the new A and B values based on the previous state within a single updater, then apply both changes atomically using a reducer or by restructuring the state updates to avoid nested side effects.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useABLoop.ts` around lines 46 - 72, The updater functions setA and
setB call other state setters inside their updater callbacks
(setLoopStart/setLoopEnd), which is impure and can run twice in Strict Mode; fix
by updating both start and end atomically instead of nesting setters: combine
loopStart and loopEnd into a single piece of state (or switch to useReducer) and
implement a single updater (e.g., setLoops or reducer action) that receives prev
{start,end}, computes newStart/newEnd (perform the swap logic there when t <
prev.start or prev.end < t) and returns the new state so both values are set
together without calling one setter from inside another; update references in
useABLoop for functions setA and setB to dispatch the new atomic updater.
src/hooks/useEqualizer.ts-100-104 (1)

100-104: ⚠️ Potential issue | 🟡 Minor

Stale source node if media element changes.

MediaElementAudioSourceNode can only be created once per HTMLMediaElement. If mediaRef.current changes to a different element, sourceRef.current still references the old source node, causing the new element to have no EQ applied.

🛡️ Suggested fix: track the connected element
+  const connectedElementRef = useRef<HTMLMediaElement | null>(null);

   // Create source (only once per element)
-  if (!sourceRef.current) {
+  if (!sourceRef.current || connectedElementRef.current !== media) {
+    if (sourceRef.current) {
+      sourceRef.current.disconnect();
+    }
     sourceRef.current = ctx.createMediaElementSource(media);
+    connectedElementRef.current = media;
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useEqualizer.ts` around lines 100 - 104, The sourceRef.current is
stale when the media element changes; update useEqualizer to track which
HTMLMediaElement is connected (e.g., store a connectedElementRef) and when media
(media or mediaRef.current) differs, disconnect and/or release the old
MediaElementAudioSourceNode and create a new one via
ctx.createMediaElementSource(media), setting sourceRef.current to the new node
and updating the connected element ref; ensure you call
sourceRef.current.disconnect() (if present) before replacing it so the old node
is not left connected.
src/components/playlist/TrackItem/TrackItem.test.tsx-7-7 (1)

7-7: ⚠️ Potential issue | 🟡 Minor

Unused import: Track type.

The Track type is imported but never used in this file. This causes TypeScript compilation errors in CI.

🔧 Remove unused import
 import { LabelsProvider } from '@/context/LabelsContext';
 import type { ReactNode } from 'react';
-import type { Track } from '@/types/player';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/playlist/TrackItem/TrackItem.test.tsx` at line 7, Remove the
unused type import by deleting the line that imports Track (the "import type {
Track } from '@/types/player';" statement) from TrackItem.test.tsx so the file
no longer references the unused symbol Track and TypeScript CI errors are
resolved; alternatively, if the test should use the type, update the test to
reference Track where appropriate (e.g., annotate mock data) instead of leaving
the import unused.
src/hooks/useCast.test.ts-314-329 (1)

314-329: ⚠️ Potential issue | 🟡 Minor

Assert that unmount removes the same listener instance.

Using expect.any(Function) here allows a broken cleanup to pass even if removeEventListener receives a different callback than the one that was registered. Capture the handler from addEventListener and assert that exact reference is removed.

🔍 Stronger cleanup assertions
 it('should remove remote event listeners on unmount', () => {
   const { video, remote } = createVideoWithRemote();
   const ref = { current: video };
   const { unmount } = renderHook(() => useCast(ref));
+  const connectHandler = remote.addEventListener.mock.calls.find(
+    ([event]: [string, Function]) => event === 'connect',
+  )?.[1];
+  const disconnectHandler = remote.addEventListener.mock.calls.find(
+    ([event]: [string, Function]) => event === 'disconnect',
+  )?.[1];

   unmount();

-  expect(remote.removeEventListener).toHaveBeenCalledWith(
-    'connect',
-    expect.any(Function),
-  );
-  expect(remote.removeEventListener).toHaveBeenCalledWith(
-    'disconnect',
-    expect.any(Function),
-  );
+  expect(remote.removeEventListener).toHaveBeenCalledWith('connect', connectHandler);
+  expect(remote.removeEventListener).toHaveBeenCalledWith('disconnect', disconnectHandler);
 });

 it('should remove AirPlay listener on unmount', () => {
   const video = createVideoWithAirPlay();
+  const addSpy = vi.spyOn(video, 'addEventListener');
   const removeSpy = vi.spyOn(video, 'removeEventListener');
   const ref = { current: video };
   const { unmount } = renderHook(() => useCast(ref));
+  const airPlayHandler = addSpy.mock.calls.find(
+    ([event]: [string, EventListener]) =>
+      event === 'webkitcurrentplaybacktargetiswirelesschanged',
+  )?.[1];

   unmount();

-  expect(removeSpy).toHaveBeenCalledWith(
-    'webkitcurrentplaybacktargetiswirelesschanged',
-    expect.any(Function),
-  );
+  expect(removeSpy).toHaveBeenCalledWith(
+    'webkitcurrentplaybacktargetiswirelesschanged',
+    airPlayHandler,
+  );
 });

Also applies to: 388-400

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useCast.test.ts` around lines 314 - 329, The test currently asserts
remote.removeEventListener was called with expect.any(Function), which can miss
mismatched handler instances; modify the test in useCast.test.ts to capture the
actual handler passed to remote.addEventListener (e.g., spy on
remote.addEventListener and save the callback for the 'connect' and 'disconnect'
events from createVideoWithRemote), then assert remote.removeEventListener was
called with the exact same handler reference for both events (use strict
equality checks), and apply the same change to the similar assertions around the
other block referenced (lines ~388-400) so both 'connect' and 'disconnect'
removals validate the exact callback instance.
src/hooks/useCast.test.ts-61-87 (1)

61-87: ⚠️ Potential issue | 🟡 Minor

Protect the prototype mutation with try/finally.

This test mutates HTMLVideoElement.prototype.remote and restores it only on the happy path. If renderHook, expect, or unmount throws, the polluted prototype leaks into later tests.

🧪 Safer cleanup pattern
   const ref = { current: document.createElement('video') };
-  const { result, unmount } = renderHook(() => useCast(ref));
-  expect(result.current.isSupported).toBe(true);
-
-  // Unmount before restoring, so the cleanup can still access video.remote
-  unmount();
-
-  // Restore to avoid polluting other tests
-  if (originalDescriptor) {
-    Object.defineProperty(HTMLVideoElement.prototype, 'remote', originalDescriptor);
-  } else {
-    delete (HTMLVideoElement.prototype as Record<string, unknown>).remote;
-  }
+  const { result, unmount } = renderHook(() => useCast(ref));
+
+  try {
+    expect(result.current.isSupported).toBe(true);
+  } finally {
+    unmount();
+    if (originalDescriptor) {
+      Object.defineProperty(HTMLVideoElement.prototype, 'remote', originalDescriptor);
+    } else {
+      delete (HTMLVideoElement.prototype as Record<string, unknown>).remote;
+    }
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useCast.test.ts` around lines 61 - 87, The test mutates
HTMLVideoElement.prototype.remote without guaranteeing restoration on failures;
wrap the prototype override and the test operations (creating ref, calling
renderHook/useCast, assertions, and unmount) inside a try/finally so that in
finally you always restore the originalDescriptor (or delete the property) using
the same logic currently present; reference the test block that uses
createRemoteMock(), Object.defineProperty(HTMLVideoElement.prototype, 'remote',
...), renderHook(() => useCast(ref)), expect(result.current.isSupported) and
unmount() and move the restoration into the finally to ensure cleanup runs even
if renderHook/expect/unmount throws.
src/components/controls/VolumeControl/VolumeControl.test.tsx-412-425 (1)

412-425: ⚠️ Potential issue | 🟡 Minor

Drop the unused container bindings.

Line 413 and Line 421 destructure container but never read it, and that is already failing the test check.

🧹 Minimal fix
-  const { container } = renderVolumeControl({ orientation: 'horizontal', volume: 0.6, muted: false });
+  renderVolumeControl({ orientation: 'horizontal', volume: 0.6, muted: false });
   const slider = screen.getByRole('slider');
@@
-  const { container } = renderVolumeControl({ orientation: 'horizontal', volume: 0.6, muted: true });
+  renderVolumeControl({ orientation: 'horizontal', volume: 0.6, muted: true });
   const slider = screen.getByRole('slider');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/controls/VolumeControl/VolumeControl.test.tsx` around lines
412 - 425, The tests renderVolumeControl(...) are destructuring an unused
container variable which fails lint/tests; update both tests ("shows correct
volume fill width (horizontal)" and "shows 0% fill when muted (horizontal)") to
call renderVolumeControl({ orientation: 'horizontal', volume: ..., muted: ... })
without destructuring container (or only destructure what you actually use), so
remove "const { container } =" and keep the rest of the test logic using
screen.getByRole and slider.querySelector as-is.
src/components/controls/ProgressBar/ProgressBar.tsx-337-344 (1)

337-344: ⚠️ Potential issue | 🟡 Minor

Normalize the A/B bounds before computing the overlay.

If callers pass loopStart > loopEnd, the computed width goes negative and the highlight disappears. The new interactive story can get into that state by setting B before A, so this branch should sort/clamp the endpoints before converting them to percentages.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/controls/ProgressBar/ProgressBar.tsx` around lines 337 - 344,
The overlay calculation in ProgressBar (using loopStart, loopEnd, and duration)
can produce negative widths when loopStart > loopEnd; normalize and clamp the
endpoints first (e.g., compute start = Math.max(0, Math.min(loopStart,
loopEnd)), end = Math.min(duration, Math.max(loopStart, loopEnd))) or
equivalent, then compute left and width from those normalized values so left =
(start/duration)*100% and width = ((end-start)/duration)*100%, ensuring width is
never negative or extends beyond bounds.
src/components/controls/ProgressBar/ProgressBar.tsx-369-375 (1)

369-375: ⚠️ Potential issue | 🟡 Minor

Route the new chapter strings through the labels system.

Go to chapter: … and Chapter {n} are hardcoded English strings even though this component already resolves labels from context. That makes the new marker buttons and tooltip content non-localizable in non-English embeds.

Also applies to: 475-485

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/controls/ProgressBar/ProgressBar.tsx` around lines 369 - 375,
The hardcoded strings "Go to chapter: …" and "Chapter {n}" in ProgressBar's
marker buttons and tooltips bypass the app's labels system; update the JSX in
the ProgressBar component (the button with key={chapter.id} and the related
tooltip code around the 475-485 area) to retrieve localized strings from the
existing labels context/resolver used elsewhere in this component (e.g., call
the same labels.resolve/labels.get helper or prop the component uses) and format
the chapter title/number into that localized template for aria-label, title and
visible label text instead of concatenating English literals.
src/components/controls/ProgressBar/ProgressBar.tsx-347-362 (1)

347-362: ⚠️ Potential issue | 🟡 Minor

Render the chapter segments behind the active fills.

These are labeled as background segments, but they’re appended after buffered progress, current progress, and the A/B overlay, so they actually paint on top of those states and mute them. Moving them earlier in the stack or giving them a lower z-index keeps them decorative.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/controls/ProgressBar/ProgressBar.tsx` around lines 347 - 362,
The chapter segment divs rendered in the chapterSegments.map are currently
painted on top of buffered/current fills and the A/B overlay; move this block so
it renders before the buffered/current progress and AB overlay elements in
ProgressBar.tsx or change the segment divs to a lower stacking context (e.g.,
add a class like 'z-0 pointer-events-none') while ensuring the progress fill
elements (buffered fill, current progress, AB overlay) have higher z-index
(e.g., 'z-10' or greater). Locate the chapterSegments mapping in ProgressBar.tsx
and either reposition it above the buffered/current fill and AB overlay JSX or
adjust the classes on both the segment divs and the fill/overlay elements to
guarantee the segments stay visually behind.
src/components/Player/Player.stories.tsx-383-383 (1)

383-383: ⚠️ Potential issue | 🟡 Minor

Language consistency is still mixed in this section.

Line 383 is now English, but nearby user-facing labels remain German. Consider fully aligning the block to one language for docs/story clarity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Player/Player.stories.tsx` at line 383, The header "Available
Features:" in Player.stories.tsx is in English while surrounding user-facing
labels remain German; pick one language and make the entire block
consistent—either translate this h4 text ("Available Features:") to the German
equivalent used elsewhere or translate neighboring German labels to English;
locate the string "Available Features:" in the Player.stories.tsx story block
and update it so all user-facing labels within that story (the h4 and nearby
labels) use the same language.
src/components/VideoPlayer/VideoPlayer.tsx-742-767 (1)

742-767: ⚠️ Potential issue | 🟡 Minor

Stale wrappedControls reference in gesture callbacks.

wrappedControls (defined at lines 706-722) is recreated on every render since it's an object literal. This causes all gesture callbacks that depend on it to be recreated, potentially causing issues with useGestures if it uses referential equality checks.

🔧 Memoize wrappedControls
-  // Wrapped controls for ad integration
-  const wrappedControls = {
+  // Wrapped controls for ad integration
+  const wrappedControls = useMemo(() => ({
     ...controls,
     play: async () => {
       if (onPlayWithAds) {
         onPlayWithAds();
       } else {
         await controls.play();
       }
     },
     toggle: async () => {
       if (!state.isPlaying && onPlayWithAds) {
         onPlayWithAds();
       } else {
         await controls.toggle();
       }
     },
-  };
+  }), [controls, onPlayWithAds, state.isPlaying]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/VideoPlayer/VideoPlayer.tsx` around lines 742 - 767, The
gesture callbacks (onDoubleTapLeft, onDoubleTapRight, onSwipeUp, onSwipeDown,
onSwipeLeft, onSwipeRight) capture a freshly created wrappedControls object each
render because wrappedControls is created as an object literal; wrap
wrappedControls creation in useMemo so its identity is stable (e.g., const
wrappedControls = useMemo(() => ({ skipForward: ..., skipBackward: ...,
setVolume: ... }), [the actual underlying control function refs]), and then
update the useCallback dependency arrays to depend on the memoized
wrappedControls (or better, on the individual stable functions inside it) so the
gesture handlers do not recreate unnecessarily; ensure you import/use useMemo
and include only the true changing dependencies (underlying control function
refs and state.volume where needed).
src/components/ads/PauseAd/PauseAd.stories.tsx-95-110 (1)

95-110: ⚠️ Potential issue | 🟡 Minor

Clear the delayed show timer when playback resumes.

The timeout id is scoped to handlePause, so handlePlay has no way to cancel it. A quick Pause → Play will still fire the 500ms callback and reopen the ad over active playback. Keep the timer in a ref and clear it when playback resumes and on unmount.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/ads/PauseAd/PauseAd.stories.tsx` around lines 95 - 110, The
pause timer in handlePause is local and cannot be cancelled by handlePlay; move
the timeout id into a ref (e.g., pauseTimerRef) so handlePause assigns
pauseTimerRef.current = setTimeout(...), have handlePlay
clearTimeout(pauseTimerRef.current) and set pauseTimerRef.current = null before
hiding the ad, and add a useEffect cleanup that clears pauseTimerRef.current on
unmount to avoid the delayed callback reopening the ad after resume.
src/components/VideoPlayer/VideoPlayer.stories.tsx-588-589 (1)

588-589: ⚠️ Potential issue | 🟡 Minor

The compliance subtitle mode state doesn't match what this story can render.

subtitleMode starts as 'overlay' and the copy says overlay/below are supported, but this story always renders mode="below" and the toggle only exposes below/off. On first render the subtitles are visible with no active control. Either add the missing overlay branch or narrow the state/copy to the two modes this story actually supports.

Also applies to: 635-635, 660-683

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/VideoPlayer/VideoPlayer.stories.tsx` around lines 588 - 589,
The story's subtitleMode state and copy are inconsistent with the UI (the story
only renders mode="below" and a toggle for below/off), so update the state and
copy to match or add the missing overlay rendering: either (A) restrict
subtitleMode's type and default to only 'below'|'off' by changing the useState
declaration (subtitleMode, setSubtitleMode) and DEFAULT_SUBTITLE_STYLE usage to
default to 'below', and update any copy/text that mentions "overlay" to only
mention "below/off"; or (B) implement the overlay branch where the component is
rendered with mode="overlay" and ensure the toggle/control exposes
overlay/below/off and the rendering logic (mode prop and subtitle rendering
section referenced in this story) supports overlay styling—make corresponding
updates to the subtitleMode state, the toggle handlers (setSubtitleMode), and
the displayed helper copy so they stay consistent across the other occurrences
noted.
src/components/ErrorBoundary/PlayerErrorBoundary.tsx-84-85 (1)

84-85: ⚠️ Potential issue | 🟡 Minor

Set explicit button type in fallback actions

Line 84 and Line 151 should set type="button" to avoid accidental form submission when this boundary is used inside a <form>.

Suggested fix
         <button
+          type="button"
           onClick={this.reset}
           className={cn(
@@
         <button
+          type="button"
           onClick={this.reset}
           className={cn(

Also applies to: 151-152

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/ErrorBoundary/PlayerErrorBoundary.tsx` around lines 84 - 85,
The fallback buttons in the PlayerErrorBoundary component can submit enclosing
forms because they lack an explicit type; update the button elements rendered in
the fallback UI (the one that calls this.reset and the other fallback action
button) to include type="button" so clicks don't trigger form submissions—locate
these in the PlayerErrorBoundary render fallback block and add type="button" to
both buttons that invoke this.reset and the secondary action.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 876b280d-7d21-4e56-8e42-f82c69689f84

📥 Commits

Reviewing files that changed from the base of the PR and between f729d6a and 6f6e843.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (142)
  • package.json
  • src/components/ErrorBoundary/PlayerErrorBoundary.stories.tsx
  • src/components/ErrorBoundary/PlayerErrorBoundary.tsx
  • src/components/ErrorBoundary/index.ts
  • src/components/Player/EpisodeView/EpisodeView.stories.tsx
  • src/components/Player/NowPlayingView/NowPlayingView.stories.tsx
  • src/components/Player/Player.stories.tsx
  • src/components/Player/Player.tsx
  • src/components/VideoPlayer/EndScreen/EndScreen.test.tsx
  • src/components/VideoPlayer/GestureOverlay/GestureOverlay.tsx
  • src/components/VideoPlayer/GestureOverlay/index.ts
  • src/components/VideoPlayer/LogoOverlay/LogoOverlay.test.tsx
  • src/components/VideoPlayer/SubtitleDisplay/SubtitleDisplay.stories.tsx
  • src/components/VideoPlayer/SubtitleDisplay/SubtitleDisplay.test.tsx
  • src/components/VideoPlayer/SubtitleDisplay/SubtitleDisplay.tsx
  • src/components/VideoPlayer/SubtitleDisplay/index.ts
  • src/components/VideoPlayer/VideoPlayer.stories.tsx
  • src/components/VideoPlayer/VideoPlayer.tsx
  • src/components/VideoPlayer/index.ts
  • src/components/a11y/ScreenReaderAnnouncer.tsx
  • src/components/a11y/index.ts
  • src/components/ads/AdOverlay/AdOverlay.stories.tsx
  • src/components/ads/AdOverlay/AdOverlay.test.tsx
  • src/components/ads/AdSkipButton/AdSkipButton.test.tsx
  • src/components/ads/PauseAd/PauseAd.stories.tsx
  • src/components/ads/PauseAd/PauseAd.test.tsx
  • src/components/ads/PauseAd/PauseAd.tsx
  • src/components/ads/PauseAd/index.ts
  • src/components/ads/RewardedAd/RewardedAd.stories.tsx
  • src/components/ads/RewardedAd/RewardedAd.test.tsx
  • src/components/ads/RewardedAd/RewardedAd.tsx
  • src/components/ads/RewardedAd/index.ts
  • src/components/ads/index.ts
  • src/components/chapters/ChapterList/ChapterList.test.tsx
  • src/components/chapters/ChapterMarker/ChapterMarker.test.tsx
  • src/components/controls/CastButton/CastButton.test.tsx
  • src/components/controls/Equalizer/Equalizer.stories.tsx
  • src/components/controls/Equalizer/Equalizer.tsx
  • src/components/controls/Equalizer/index.ts
  • src/components/controls/FullscreenButton/FullscreenButton.test.tsx
  • src/components/controls/NowPlayingIndicator/NowPlayingIndicator.test.tsx
  • src/components/controls/PictureInPictureButton/PictureInPictureButton.test.tsx
  • src/components/controls/PlayButton/PlayButton.test.tsx
  • src/components/controls/PlaybackSpeed/PlaybackSpeed.test.tsx
  • src/components/controls/ProgressBar/ProgressBar.stories.tsx
  • src/components/controls/ProgressBar/ProgressBar.test.tsx
  • src/components/controls/ProgressBar/ProgressBar.tsx
  • src/components/controls/ProgressBar/ThumbnailPreview.tsx
  • src/components/controls/ShareButton/ShareButton.stories.tsx
  • src/components/controls/ShareButton/ShareButton.tsx
  • src/components/controls/ShareButton/index.ts
  • src/components/controls/SkipButtons/SkipButtons.test.tsx
  • src/components/controls/SleepTimer/SleepTimer.tsx
  • src/components/controls/SleepTimer/index.ts
  • src/components/controls/SubtitleSelector/SubtitleSelector.test.tsx
  • src/components/controls/SubtitleSettings/SubtitleSettings.stories.tsx
  • src/components/controls/SubtitleSettings/SubtitleSettings.tsx
  • src/components/controls/SubtitleSettings/index.ts
  • src/components/controls/TimeDisplay/TimeDisplay.test.tsx
  • src/components/controls/VolumeControl/VolumeControl.test.tsx
  • src/components/controls/index.ts
  • src/components/markers/MarkerList/MarkerList.test.tsx
  • src/components/playlist/PlaylistView/PlaylistView.stories.tsx
  • src/components/playlist/PlaylistView/PlaylistView.test.tsx
  • src/components/playlist/TrackItem/TrackItem.test.tsx
  • src/components/podcast/PodcastPage.stories.tsx
  • src/components/stats/Rating.stories.tsx
  • src/components/stats/Rating.test.tsx
  • src/components/stats/Stats.stories.tsx
  • src/components/stats/Stats.test.tsx
  • src/context/AdContext.test.tsx
  • src/context/FairuProvider.tsx
  • src/context/LabelsContext.test.tsx
  • src/context/PlayerContext.test.tsx
  • src/context/TrackingContext.test.tsx
  • src/context/TrackingContext.tsx
  • src/context/VideoAdContext.test.tsx
  • src/context/VideoContext.test.tsx
  • src/context/index.ts
  • src/embed/parseConfig.test.ts
  • src/examples/NewFeaturesDemo.stories.tsx
  • src/hooks/index.ts
  • src/hooks/useABLoop.ts
  • src/hooks/useAudio.test.ts
  • src/hooks/useAutoplayDetection.ts
  • src/hooks/useCast.test.ts
  • src/hooks/useChapters.test.ts
  • src/hooks/useEqualizer.ts
  • src/hooks/useFocusTrap.ts
  • src/hooks/useFullscreen.test.ts
  • src/hooks/useGestures.ts
  • src/hooks/useHLS.test.ts
  • src/hooks/useKeyboardControls.test.ts
  • src/hooks/useKeyboardControls.ts
  • src/hooks/useMarkers.test.ts
  • src/hooks/useMedia.test.ts
  • src/hooks/useMedia.ts
  • src/hooks/usePauseAd.test.ts
  • src/hooks/usePauseAd.ts
  • src/hooks/usePictureInPicture.test.ts
  • src/hooks/usePlaybackHistory.ts
  • src/hooks/usePlayer.test.tsx
  • src/hooks/usePlaylist.test.ts
  • src/hooks/usePlaylistPersistence.ts
  • src/hooks/useResumePosition.ts
  • src/hooks/useRewardedAd.test.ts
  • src/hooks/useRewardedAd.ts
  • src/hooks/useShareableTimestamp.ts
  • src/hooks/useSleepTimer.ts
  • src/hooks/useSubtitleParser.test.ts
  • src/hooks/useSubtitleParser.ts
  • src/hooks/useSubtitleStyling.ts
  • src/hooks/useSyncPlayback.ts
  • src/hooks/useTabVisibility.test.ts
  • src/hooks/useVideo.test.ts
  • src/index.ts
  • src/services/AdService.test.ts
  • src/services/SyncService.ts
  • src/services/TrackingService.test.ts
  • src/services/TrackingService.ts
  • src/services/index.ts
  • src/test/helpers.tsx
  • src/test/setup.ts
  • src/types/abloop.ts
  • src/types/equalizer.ts
  • src/types/history.ts
  • src/types/index.ts
  • src/types/media.ts
  • src/types/pauseAd.ts
  • src/types/playlistPersistence.ts
  • src/types/resume.ts
  • src/types/rewardedAd.ts
  • src/types/sleepTimer.ts
  • src/types/subtitleStyling.ts
  • src/types/sync.ts
  • src/types/tracking.ts
  • src/types/video.ts
  • src/utils/PlayerEventBus.test.ts
  • src/utils/fairu.test.ts
  • src/utils/index.ts
  • src/utils/thumbnails.ts
  • vite.config.ts

politeness = 'polite',
}: ScreenReaderAnnouncerProps) {
const [announcement, setAnnouncement] = useState('');
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== React package versions =="
fd -i package.json --exec sh -c '
  echo "-- $1"
  rg -n "\"(react|react-dom|@types/react|@types/react-dom)\"\\s*:" "$1" || true
' sh {}

echo
echo "== useRef calls without initializer =="
rg -nP --type ts --type tsx '\buseRef(?:<[^>]+>)?\s*\(\s*\)'

Repository: sushidev-team/fairu-player

Length of output: 411


🏁 Script executed:

cat -n src/components/a11y/ScreenReaderAnnouncer.tsx | head -30

Repository: sushidev-team/fairu-player

Length of output: 1175


🏁 Script executed:

# Try different patterns to find useRef without initializer
rg -n "useRef" src/components/a11y/ScreenReaderAnnouncer.tsx

Repository: sushidev-team/fairu-player

Length of output: 189


🏁 Script executed:

# Check tsconfig for React 19 JSX handling and type strictness
cat tsconfig.json

Repository: sushidev-team/fairu-player

Length of output: 784


🏁 Script executed:

# Verify `@types/react` version requirement details for useRef
web_search
React 19 useRef type definition initializer requirement `@types/react`

Repository: sushidev-team/fairu-player

Length of output: 164


🌐 Web query:

React 19 useRef type definition initializer requirement

💡 Result:

In React 19 with TypeScript (via @types/react ^19.0.0), the useRef hook requires an initializer argument. Previously in React 18, useRef without arguments was allowed (implicitly initializing to {current: undefined}), but this is no longer permitted. useRef(initialValue) returns RefObject where RefObject = { current: T }, and all refs are now mutable by default (no more readonly current when initialized with null). For DOM refs, use useRef(null), which has a convenience overload returning RefObject<ElementType | null>. Examples: - const inputRef = useRef(null); // RefObject<HTMLInputElement | null> - const valueRef = useRef(0); // RefObject - const objRef = useRef({}); // RefObject<{}> - const undefinedRef = useRef(undefined); // Allowed for migration, RefObject Attempting useRef without argument results in a TypeScript error: "Expected 1 argument, but got 0." This change simplifies the type signature, aligns useRef with hooks like useState/createContext, eliminates MutableRefObject vs RefObject confusion, and makes all refs mutable to avoid common "Cannot assign to read-only current" errors when initializing with null. Official docs confirm useRef requires initialValue: https://react.dev/reference/react/useRef. Upgrade codemod available: npx types-react-codemod@latest preset-19 (includes useRef-required-initial transformation).

Citations:


🏁 Script executed:

# Check TypeScript and React versions in package.json
rg -A2 '"typescript"|"react"' package.json

Repository: sushidev-team/fairu-player

Length of output: 458


🏁 Script executed:

# Check if there's a cleanup function that handles the timeout
cat -n src/components/a11y/ScreenReaderAnnouncer.tsx | sed -n '21,50p'

Repository: sushidev-team/fairu-player

Length of output: 975


Initialize useRef with null for React 19 type compatibility.

Line 19 uses useRef without an initial value. While this works with @types/react 18, the project's peer dependency supports React 19 (^18.0.0 || ^19.0.0). React 19 requires an initializer argument for useRef, and upgrading to @types/react 19 will cause a TypeScript error: "Expected 1 argument, but got 0."

Proposed fix
-  const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
+  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/a11y/ScreenReaderAnnouncer.tsx` at line 19, The timeoutRef in
ScreenReaderAnnouncer is created without an initial value which breaks React 19
typings; change the declaration of timeoutRef to include a null initializer
(e.g., useRef<ReturnType<typeof setTimeout> | null>(null)) and update any usages
(clearTimeout(timeoutRef.current) / timeoutRef.current = ...) to handle the
nullable type (use optional chaining or null checks) so TypeScript compiles
under React 19.

Comment on lines +35 to +43
<div
className={cn(
'absolute inset-0 z-40 flex items-center justify-center',
'bg-black/60 backdrop-blur-sm',
'animate-in fade-in duration-300',
className
)}
data-testid="pause-ad"
>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Expose the pause ad as a modal dialog.

This overlay is rendered as a plain <div>, so assistive tech gets no dialog announcement and focus can stay behind the ad. Add role="dialog", aria-modal, an accessible name, and move focus into the overlay when it opens.

💡 Suggested starting point
     <div
+      role="dialog"
+      aria-modal="true"
+      aria-label={ad.title || 'Advertisement'}
       className={cn(
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
className={cn(
'absolute inset-0 z-40 flex items-center justify-center',
'bg-black/60 backdrop-blur-sm',
'animate-in fade-in duration-300',
className
)}
data-testid="pause-ad"
>
<div
role="dialog"
aria-modal="true"
aria-label={ad.title || 'Advertisement'}
className={cn(
'absolute inset-0 z-40 flex items-center justify-center',
'bg-black/60 backdrop-blur-sm',
'animate-in fade-in duration-300',
className
)}
data-testid="pause-ad"
>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/ads/PauseAd/PauseAd.tsx` around lines 35 - 43, The overlay div
in the PauseAd component is not accessible as a modal dialog; update the element
rendered in PauseAd (the div with data-testid="pause-ad") to include
role="dialog", aria-modal="true" and an accessible name (either aria-label or
aria-labelledby pointing to a title element inside the dialog), add
tabIndex={-1} and a ref (e.g., containerRef), then in PauseAd use a useEffect
that, when the overlay opens, saves document.activeElement, moves focus into
containerRef.current (or the first focusable element), and on close restores
focus to the previously focused element; ensure the title element has an id if
using aria-labelledby and keep these changes scoped to the PauseAd component.

Comment on lines +47 to +53
useEffect(() => {
if (visible && videoRef.current) {
videoRef.current.play().catch(() => {
// Autoplay blocked - user needs to interact
});
}
}, [visible]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Autoplay failure can dead-end the user behind the overlay

At Line 49, autoplay rejection is ignored, but the UI has no guaranteed manual play path while close stays gated behind completion. Downstream impact: user can get stuck and never reach onClose.

Suggested fix (track autoplay block + provide manual start CTA)
   const [isPlaying, setIsPlaying] = useState(false);
   const [isCompleted, setIsCompleted] = useState(false);
+  const [autoplayBlocked, setAutoplayBlocked] = useState(false);
   const rewardedRef = useRef(false);

   // Auto-play when visible
   useEffect(() => {
     if (visible && videoRef.current) {
-      videoRef.current.play().catch(() => {
-        // Autoplay blocked - user needs to interact
-      });
+      videoRef.current
+        .play()
+        .then(() => setAutoplayBlocked(false))
+        .catch(() => setAutoplayBlocked(true));
     }
   }, [visible]);
       <div className="relative w-full max-w-2xl aspect-video rounded-xl overflow-hidden">
         <video
@@
           data-testid="rewarded-ad-video"
         />
+
+        {autoplayBlocked && !isCompleted && (
+          <button
+            type="button"
+            onClick={() => videoRef.current?.play()}
+            className="absolute inset-0 m-auto h-fit w-fit px-4 py-2 rounded-md bg-black/70 text-white text-sm"
+          >
+            Tap to play ad
+          </button>
+        )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/ads/RewardedAd/RewardedAd.tsx` around lines 47 - 53, The
useEffect in RewardedAd currently swallows autoplay rejections causing users to
be stuck; update the component (RewardedAd) to catch autoplay rejection from
videoRef.play(), set a local state flag (e.g., autoplayBlocked) when
play().catch triggers, and render a visible manual play CTA and an enabled close
path (so onClose can be invoked) whenever autoplayBlocked or video is paused;
ensure the play CTA calls videoRef.current.play() and clears autoplayBlocked on
success, and that the close control is not gated only by video completion.

Comment on lines +78 to +80
if (ad.clickThroughUrl) {
window.open(ad.clickThroughUrl, '_blank', 'noopener,noreferrer');
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate click-through URL scheme before window.open

Line 79 opens ad.clickThroughUrl directly. If ad payloads are external/untrusted, unsafe schemes (e.g., non-http(s)) should be rejected.

Suggested fix
   const handleClick = useCallback(() => {
-    if (ad.clickThroughUrl) {
-      window.open(ad.clickThroughUrl, '_blank', 'noopener,noreferrer');
-    }
+    if (ad.clickThroughUrl) {
+      try {
+        const url = new URL(ad.clickThroughUrl, window.location.href);
+        if (url.protocol === 'http:' || url.protocol === 'https:') {
+          window.open(url.toString(), '_blank', 'noopener,noreferrer');
+        }
+      } catch {
+        // Ignore invalid click-through URL
+      }
+    }
     onClick?.(ad);
   }, [ad, onClick]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (ad.clickThroughUrl) {
window.open(ad.clickThroughUrl, '_blank', 'noopener,noreferrer');
}
if (ad.clickThroughUrl) {
try {
const url = new URL(ad.clickThroughUrl, window.location.href);
if (url.protocol === 'http:' || url.protocol === 'https:') {
window.open(url.toString(), '_blank', 'noopener,noreferrer');
}
} catch {
// Ignore invalid click-through URL
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/ads/RewardedAd/RewardedAd.tsx` around lines 78 - 80, The
click-through handler in the RewardedAd component currently calls
window.open(ad.clickThroughUrl) without validating the URL scheme; update the
handler (where ad.clickThroughUrl is used) to parse the URL (e.g., using the URL
constructor) and only call window.open if the protocol is "http:" or "https:",
otherwise reject the value (no-op or log) to prevent unsafe schemes; also guard
the parse in a try/catch to handle malformed URLs and avoid throwing.

Comment thread src/components/controls/ProgressBar/ProgressBar.test.tsx Outdated
Comment on lines +68 to +76
// Optionally wrap with AdProvider
if (ads) {
content = <AdProvider config={ads}>{content}</AdProvider>;
}

// Optionally wrap with TrackingProvider
if (tracking) {
content = <TrackingProvider config={tracking}>{content}</TrackingProvider>;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep the provider tree stable across config changes.

Conditionally inserting AdProvider or TrackingProvider changes the root element type when those props appear or disappear. That remounts PlayerProvider and resets playback/context state if ads or tracking config loads asynchronously.

Prefer always-rendered wrappers that no-op when config is absent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/context/FairuProvider.tsx` around lines 68 - 76, The conditional
rendering of AdProvider and TrackingProvider causes the root element type to
change and remount PlayerProvider when configs appear/disappear; always render
the wrappers instead and make them no-op when config is absent: replace the
if-blocks in FairuProvider.tsx so AdProvider and TrackingProvider are always
present around content (wrapping PlayerProvider) and update their
props/implementation to accept a nullable config (or introduce
NullAdProvider/NullTrackingProvider that forwards children unchanged) so
PlayerProvider is not unmounted when ads or tracking load asynchronously.

Comment on lines +43 to +55
const [enabled, setEnabledState] = useState(config.enabled);
const serviceRef = useRef<TrackingService | null>(null);

// Flush event queue
const flush = useCallback(async () => {
if (eventQueue.current.length === 0) return;
const events = [...eventQueue.current];
eventQueue.current = [];
await sendEvents(events);
}, [sendEvents]);
// Initialize / recreate the tracking service when config changes
useEffect(() => {
const service = new TrackingService({ ...config, enabled });
serviceRef.current = service;

return () => {
service.destroy();
serviceRef.current = null;
};
}, [config, enabled]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep enabled in sync with prop updates.

useState(config.enabled) only reads the prop on the first render. If a parent later flips tracking.enabled, this provider keeps the old value and recreates TrackingService with stale settings, so external config changes never take effect.

💡 Suggested fix
   const [enabled, setEnabledState] = useState(config.enabled);
+
+  useEffect(() => {
+    setEnabledState(config.enabled);
+  }, [config.enabled]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [enabled, setEnabledState] = useState(config.enabled);
const serviceRef = useRef<TrackingService | null>(null);
// Flush event queue
const flush = useCallback(async () => {
if (eventQueue.current.length === 0) return;
const events = [...eventQueue.current];
eventQueue.current = [];
await sendEvents(events);
}, [sendEvents]);
// Initialize / recreate the tracking service when config changes
useEffect(() => {
const service = new TrackingService({ ...config, enabled });
serviceRef.current = service;
return () => {
service.destroy();
serviceRef.current = null;
};
}, [config, enabled]);
const [enabled, setEnabledState] = useState(config.enabled);
useEffect(() => {
setEnabledState(config.enabled);
}, [config.enabled]);
const serviceRef = useRef<TrackingService | null>(null);
// Initialize / recreate the tracking service when config changes
useEffect(() => {
const service = new TrackingService({ ...config, enabled });
serviceRef.current = service;
return () => {
service.destroy();
serviceRef.current = null;
};
}, [config, enabled]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/context/TrackingContext.tsx` around lines 43 - 55, The local state
"enabled" (created via useState(config.enabled)) can become stale when the
parent updates config.enabled; add an effect to sync it from props by calling
setEnabledState(config.enabled) whenever config.enabled changes (so that the
existing useEffect which constructs TrackingService with { ...config, enabled }
gets the updated value), or alternatively remove the local enabled state and use
config.enabled directly when instantiating TrackingService; update code
referencing enabled, setEnabledState, serviceRef, and the useEffect that creates
TrackingService accordingly.

Comment on lines +37 to +46
beforeEach(() => {
vi.useFakeTimers();
globalThis.fetch = mockFetch as unknown as typeof fetch;
mockFetch.mockClear();
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's check if the file exists and read the relevant section
find . -name "VideoAdContext.test.tsx" -type f

Repository: sushidev-team/fairu-player

Length of output: 107


🏁 Script executed:

# Also search for the file more broadly
fd -i "videoadcontext" --type f

Repository: sushidev-team/fairu-player

Length of output: 136


🏁 Script executed:

# Read the test file to verify the code snippet
cat -n src/context/VideoAdContext.test.tsx | head -60

Repository: sushidev-team/fairu-player

Length of output: 2155


🏁 Script executed:

# Check if there are other tests in the repo that handle global fetch or similar patterns
rg -t js -t tsx "globalThis\." src/ --max-count=20

Repository: sushidev-team/fairu-player

Length of output: 97


🏁 Script executed:

# Check if there are other afterEach patterns that restore globals
rg -t js -t tsx "afterEach" src/ -A 3 --max-count=20

Repository: sushidev-team/fairu-player

Length of output: 97


🏁 Script executed:

# Search for globalThis patterns without file type restriction
rg "globalThis\." src/ -A 2 -B 2

Repository: sushidev-team/fairu-player

Length of output: 1681


🏁 Script executed:

# Search for fetch restoration patterns in test files
rg "originalFetch\|globalThis.fetch" --no-heading

Repository: sushidev-team/fairu-player

Length of output: 52


🌐 Web query:

Vitest vi.restoreAllMocks() global reassignment fetch globalThis

💡 Result:

In Vitest, vi.restoreAllMocks restores spies created with vi.spyOn or vi.fn, but does not automatically restore direct global reassignments like globalThis.fetch = vi.fn or vi.stubGlobal('fetch', ...). For mocking the global fetch (often via globalThis.fetch), common patterns include: 1. Direct assignment: globalThis.fetch = vi.fn( => Promise.resolve(mockResponse)). This fully replaces fetch. vi.restoreAllMocks does not restore it; use vi.unstubAllGlobals if using vi.stubGlobal, or manual restoration like saving/restoring the original in beforeEach/afterEach. 2. vi.spyOn(globalThis, 'fetch'): Creates a spy on the existing fetch. This works with vi.restoreAllMocks to restore the original implementation and clear history. vi.restoreAllMocks calls .mockRestore on all spies, preserving original behavior for spied globals but not handling non-spied reassignments. Official docs recommend vi.stubGlobal for globals with unstubGlobals: true in config for auto-reset, or vi.unstubAllGlobals manually. For fetch specifically, libraries like vitest-fetch-mock or MSW (@mswjs/interceptors in Node) handle global overriding with their own reset methods integrated with Vitest hooks. Example for spyOn (restorable with vi.restoreAllMocks): const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); afterEach( => { vi.restoreAllMocks; }); Example for direct assignment (requires unstubAllGlobals or manual reset): const originalFetch = globalThis.fetch; globalThis.fetch = vi.fn; afterEach( => { globalThis.fetch = originalFetch; }); Or with vi.stubGlobal('fetch', vi.fn): afterEach(vi.unstubAllGlobals); Direct global reassignments persist across tests unless explicitly reset, leading to leakage if not handled in hooks.

Citations:


Restore the original fetch after each test.

globalThis.fetch is reassigned directly in beforeEach, and vi.restoreAllMocks() does not undo direct reassignments to global objects. The mock will leak into subsequent tests, potentially causing unrelated assertions to pass or fail for the wrong reason.

Suggested fix
+const originalFetch = globalThis.fetch;
+
 beforeEach(() => {
   vi.useFakeTimers();
   globalThis.fetch = mockFetch as unknown as typeof fetch;
   mockFetch.mockClear();
 });
 
 afterEach(() => {
   vi.useRealTimers();
   vi.restoreAllMocks();
+  globalThis.fetch = originalFetch;
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
beforeEach(() => {
vi.useFakeTimers();
globalThis.fetch = mockFetch as unknown as typeof fetch;
mockFetch.mockClear();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.useFakeTimers();
globalThis.fetch = mockFetch as unknown as typeof fetch;
mockFetch.mockClear();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
globalThis.fetch = originalFetch;
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/context/VideoAdContext.test.tsx` around lines 37 - 46, Tests reassign
globalThis.fetch in the beforeEach block causing leakage; save the original
fetch (e.g., const originalFetch = globalThis.fetch) before assigning mockFetch
in beforeEach, and then restore it in afterEach by setting globalThis.fetch =
originalFetch; keep existing vi.restoreAllMocks() and vi.useRealTimers() but
ensure globalThis.fetch is explicitly reverted to the saved original so the
mockFetch does not leak across tests (refer to beforeEach, afterEach,
globalThis.fetch, mockFetch, vi.useFakeTimers, vi.restoreAllMocks).

Comment on lines +199 to +204
const onPlay = vi.fn();
const onPause = vi.fn();
const onEnded = vi.fn();
const onError = vi.fn();
const onTimeUpdate = vi.fn();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Vitest config files =="
fd -i 'vitest.config.*'

echo "== globals setting in test configs =="
rg -n --iglob '*vitest*.*' 'globals\s*:'

echo "== vi usage in tests =="
rg -nP --type=ts --type=tsx '\bvi\.' -g '!**/node_modules/**'

echo "== explicit vitest imports =="
rg -nP --type=ts --type=tsx "from ['\"]vitest['\"]"

Repository: sushidev-team/fairu-player

Length of output: 299


🏁 Script executed:

# Read the test file to see imports and vi usage
cat -n src/hooks/useAudio.test.ts | head -30

Repository: sushidev-team/fairu-player

Length of output: 1432


🏁 Script executed:

# Check the entire beginning of the file including imports
head -200 src/hooks/useAudio.test.ts | tail -50

Repository: sushidev-team/fairu-player

Length of output: 1256


🏁 Script executed:

# Look for vitest config files more broadly
find . -maxdepth 2 -name "*vitest*" -o -name "vite.config.*" 2>/dev/null | head -20

Repository: sushidev-team/fairu-player

Length of output: 86


🏁 Script executed:

# Check for package.json vitest config
cat package.json 2>/dev/null | grep -A 10 '"vitest"'

Repository: sushidev-team/fairu-player

Length of output: 609


🏁 Script executed:

# Look for vi imports across test files
rg "from ['\"]vitest['\"]" src/ 2>/dev/null | head -20

Repository: sushidev-team/fairu-player

Length of output: 1937


🏁 Script executed:

cat vite.config.ts

Repository: sushidev-team/fairu-player

Length of output: 4377


🏁 Script executed:

sed -n '195,210p' src/hooks/useAudio.test.ts

Repository: sushidev-team/fairu-player

Length of output: 445


Import vi explicitly to match codebase convention.

vi.fn() is used at lines 199-204, but vi is not imported. While globals: true in vite.config.ts enables global vi access, all other test files in the codebase explicitly import vi from vitest. Add the import to maintain consistency:

 import { renderHook, act } from '@testing-library/react';
+import { vi } from 'vitest';
 import { useAudio } from './useAudio';
 import { fireMediaEvent, simulateTimeUpdate, simulateLoadedMetadata } from '@/test/helpers';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const onPlay = vi.fn();
const onPause = vi.fn();
const onEnded = vi.fn();
const onError = vi.fn();
const onTimeUpdate = vi.fn();
import { renderHook, act } from '@testing-library/react';
import { vi } from 'vitest';
import { useAudio } from './useAudio';
import { fireMediaEvent, simulateTimeUpdate, simulateLoadedMetadata } from '@/test/helpers';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useAudio.test.ts` around lines 199 - 204, The test file uses
vi.fn() for mocks (onPlay, onPause, onEnded, onError, onTimeUpdate) but does not
import vi; add an explicit import "import { vi } from 'vitest'" at the top of
src/hooks/useAudio.test.ts to follow project convention and ensure the vi symbol
is resolved consistently across tests.

@@ -0,0 +1,118 @@
import { useState, useEffect, useCallback, useRef } from 'react';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

requestPlay is part of the public contract, but it currently does nothing.

Consumers calling the returned method after a user gesture will never recover blocked autoplay, even though the API says they can. Either implement it against a supplied media ref/callback, or remove it until that path exists.

🧩 One safe option is to remove the placeholder API for now
-import { useState, useEffect, useCallback, useRef } from 'react';
+import { useState, useEffect, useRef } from 'react';
@@
 export interface AutoplayPolicy {
   /** Whether autoplay with sound is allowed */
   canAutoplay: boolean;
   /** Whether autoplay is allowed when muted */
   canAutoplayMuted: boolean;
   /** Whether detection has completed */
   detected: boolean;
-  /** If autoplay was blocked, call this to attempt playing after user gesture */
-  requestPlay: () => void;
 }
@@
-  const requestPlay = useCallback(() => {
-    // No-op placeholder — consumers can replace this to trigger play after a user gesture
-  }, []);
-
   useEffect(() => {
@@
   return {
     canAutoplay,
     canAutoplayMuted,
     detected,
-    requestPlay,
   };
 }

Also applies to: 10-11, 49-51, 112-117

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useAutoplayDetection.ts` at line 1, The exported requestPlay
function in useAutoplayDetection.ts is currently a no-op and must either be
implemented or removed; implement it by accepting the media element
reference/callback used by the hook (e.g., the same ref passed into the hook or
a setMediaRef callback) and attempt to call mediaRef.current.play(), handling
the returned Promise and caught DOMException by setting the hook's state
(isAutoplayBlocked/isAutoplayAllowed) and resolving/rejecting appropriately;
update the hook's returned API to include this functional requestPlay and ensure
it uses the existing internal state setters (the same ones referenced around
lines where requestPlay is declared/returned) so consumers can call
requestPlay() after a user gesture to recover from blocked autoplay.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (6)
src/components/controls/VolumeControl/VolumeControl.test.tsx (3)

175-187: Close the drag cycle in this test to prevent lingering global listeners.

At Line 181, drag starts but the test never sends mouseUp. If cleanup depends on mouseup, this can make later tests flaky.

Suggested change
   fireEvent.mouseMove(document, { clientX: 72, clientY: 12 });
   expect(onVolumeChange).toHaveBeenCalled();
+  fireEvent.mouseUp(document);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/controls/VolumeControl/VolumeControl.test.tsx` around lines
175 - 187, The test "calls onVolumeChange on drag (horizontal)" starts a drag
with fireEvent.mouseDown but never ends it, leaving global listeners active;
update the test in VolumeControl.test.tsx (the one that uses
renderVolumeControl, mockSliderRect, fireEvent.mouseDown, and
fireEvent.mouseMove) to dispatch a corresponding mouseUp (e.g.,
fireEvent.mouseUp(document)) after the mouseMove assertion to close the drag
cycle and ensure global listeners are removed/cleaned up.

1-1: Improve test isolation by restoring spies after each test.

getBoundingClientRect spies are created repeatedly; adding teardown avoids hidden coupling between tests and keeps failures easier to diagnose.

Suggested change
-import { describe, it, expect, vi } from 'vitest';
+import { describe, it, expect, vi, afterEach } from 'vitest';
...
 describe('VolumeControl', () => {
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });

Also applies to: 53-53, 25-51

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/controls/VolumeControl/VolumeControl.test.tsx` at line 1,
Tests in VolumeControl.test.tsx create spies for getBoundingClientRect
repeatedly without teardown; add an afterEach that calls vi.restoreAllMocks()
(or explicitly calls mockRestore() on any vi.spyOn instances) to restore
getBoundingClientRect spies between tests so they don't leak state across cases;
ensure the afterEach is placed alongside the existing describe/it blocks so
vi.restoreAllMocks() runs after each test.

257-267: Consider reducing Tailwind-implementation coupling in selectors.

These assertions depend on specific utility-class chains and internal DOM structure. Prefer stable selectors (data-testid) for overlay/fill elements and keep class checks for cases where style class itself is the behavior under test.

Also applies to: 384-394, 412-425

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/controls/VolumeControl/VolumeControl.test.tsx` around lines
257 - 267, The tests rely on fragile Tailwind class selectors and DOM structure;
update the VolumeControl component to add stable data-testid attributes for the
overlay/fill elements (e.g., data-testid="volume-overlay" and
data-testid="volume-fill") and change the tests (e.g., the 'hides vertical
slider on mouse leave (when not dragging)' spec) to query those elements via
getByTestId (or screen.getByTestId) instead of
container.querySelector('.absolute.bottom-full'); continue to assert the opacity
class only when the class itself is the behavior under test (otherwise assert
visibility via computed styles or aria attributes), and apply the same update
pattern to the other tests referenced (lines ~384-394 and ~412-425) that
currently rely on utility-class chains.
src/examples/NewFeaturesDemo.stories.tsx (1)

21-21: Use a single source of truth for demo duration.

ProgressBar uses a hardcoded 596 while the track already defines duration. Using sampleVideo.duration avoids silent drift.

♻️ Proposed fix
-            duration={596}
+            duration={sampleVideo.duration}

Also applies to: 388-388

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/examples/NewFeaturesDemo.stories.tsx` at line 21, Replace the hardcoded
duration value used in ProgressBar with the single source of truth (the demo
track's duration) — find the ProgressBar prop currently set to 596 and change it
to use sampleVideo.duration (or the existing track.duration variable) and do the
same for the other occurrence around line 388; ensure the value passed is a
number and matches the shape expected by ProgressBar so both instances reference
the track's duration instead of the literal 596.
src/embed/parseConfig.test.ts (2)

118-197: Consider table-driven tests to reduce duplication.

Large repeated true/false and default/override assertions are good coverage, but this can be compressed with it.each(...) to keep future updates safer and easier.

Also applies to: 540-604, 606-708

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/embed/parseConfig.test.ts` around lines 118 - 197, The tests under
describe('features parsing') are repetitive; refactor them to a data-driven
table using Jest's it.each to reduce duplication: create a matrix of input
attribute keys/values and expected feature keys (e.g., rows like {attr:
'chapters', value: 'false', feature: 'chapters', expected: false} and default
rows for true cases), then replace individual it blocks with a single
parametrized test that calls createElement and parseDataAttributes and asserts
config.player.features[feature] === expected; also consolidate the "defaults all
features to true" as one smoke test and keep the mixed-values case as a separate
table-driven row set to verify non-"false" values remain true. Ensure tests
reference the parseDataAttributes and createElement helpers so behavior stays
identical.

82-87: Codify malformed JSON behavior with explicit tests.

These sections only cover valid JSON. Add tests for malformed chapters / playlist / speeds so parser behavior (throw vs fallback) is intentional and protected from regressions.

Also applies to: 99-108, 284-290, 522-531

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/embed/parseConfig.test.ts` around lines 82 - 87, Add explicit tests in
src/embed/parseConfig.test.ts that cover malformed JSON inputs for the data
attributes handled by parseDataAttributes (specifically "chapters", "playlist",
and "speeds") so the parser's current behavior is codified and protected from
regressions; use the same test patterns as the existing valid-JSON tests
(createElement helper, call parseDataAttributes) but pass invalid JSON strings
and assert the current observed outcome (either expect the call to throw or
expect a safe fallback value on
config.player.track!/config.playlist!/config.speeds), and replicate this for the
other locations referenced in the comment to ensure consistent behavior across
parseDataAttributes and any helper parsing functions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/playlist/TrackItem/TrackItem.test.tsx`:
- Around line 65-74: The tests in TrackItem.test.tsx use getByRole('img') /
queryByRole('img') which can match the artwork <img> in TrackItem.tsx as well as
the NowPlayingIndicator; update the assertions to target the NowPlayingIndicator
by its accessible name instead of any img role (e.g., use getByRole('img', {
name: /now playing/i }) and queryByRole('img', { name: /now playing/i })) so
renderTrackItem(...) still renders the artwork but the test only checks the
specific indicator.

In `@src/embed/parseConfig.test.ts`:
- Around line 134-139: Add a regression test to cover the interaction where an
element has a src and chapters="false": create an element with createElement({
src: 'some.mp4', chapters: 'false' }), call parseDataAttributes(el) and assert
that config.player.features!.chapters is false (and that no chapters track array
was parsed); this ensures parseDataAttributes handles the chapters="false"
string correctly when src is present.

In `@src/examples/NewFeaturesDemo.stories.tsx`:
- Around line 333-350: Guard the loop bounds before passing them to the
ProgressBar: ensure loopStart and loopEnd are non-null and that loopStart <=
loopEnd (normalize by swapping or clamping if inverted) wherever you compute the
preview or render ProgressBar (e.g., where loopStart, loopEnd are used after
setLoopStart/setLoopEnd and in the second block around lines 370-393); update
the code that supplies the loop region to compute safeStart =
Math.min(loopStart, loopEnd) and safeEnd = Math.max(loopStart, loopEnd) (or skip
rendering the preview when either is null) and pass safeStart/safeEnd to
ProgressBar to avoid sending an inverted range.

---

Nitpick comments:
In `@src/components/controls/VolumeControl/VolumeControl.test.tsx`:
- Around line 175-187: The test "calls onVolumeChange on drag (horizontal)"
starts a drag with fireEvent.mouseDown but never ends it, leaving global
listeners active; update the test in VolumeControl.test.tsx (the one that uses
renderVolumeControl, mockSliderRect, fireEvent.mouseDown, and
fireEvent.mouseMove) to dispatch a corresponding mouseUp (e.g.,
fireEvent.mouseUp(document)) after the mouseMove assertion to close the drag
cycle and ensure global listeners are removed/cleaned up.
- Line 1: Tests in VolumeControl.test.tsx create spies for getBoundingClientRect
repeatedly without teardown; add an afterEach that calls vi.restoreAllMocks()
(or explicitly calls mockRestore() on any vi.spyOn instances) to restore
getBoundingClientRect spies between tests so they don't leak state across cases;
ensure the afterEach is placed alongside the existing describe/it blocks so
vi.restoreAllMocks() runs after each test.
- Around line 257-267: The tests rely on fragile Tailwind class selectors and
DOM structure; update the VolumeControl component to add stable data-testid
attributes for the overlay/fill elements (e.g., data-testid="volume-overlay" and
data-testid="volume-fill") and change the tests (e.g., the 'hides vertical
slider on mouse leave (when not dragging)' spec) to query those elements via
getByTestId (or screen.getByTestId) instead of
container.querySelector('.absolute.bottom-full'); continue to assert the opacity
class only when the class itself is the behavior under test (otherwise assert
visibility via computed styles or aria attributes), and apply the same update
pattern to the other tests referenced (lines ~384-394 and ~412-425) that
currently rely on utility-class chains.

In `@src/embed/parseConfig.test.ts`:
- Around line 118-197: The tests under describe('features parsing') are
repetitive; refactor them to a data-driven table using Jest's it.each to reduce
duplication: create a matrix of input attribute keys/values and expected feature
keys (e.g., rows like {attr: 'chapters', value: 'false', feature: 'chapters',
expected: false} and default rows for true cases), then replace individual it
blocks with a single parametrized test that calls createElement and
parseDataAttributes and asserts config.player.features[feature] === expected;
also consolidate the "defaults all features to true" as one smoke test and keep
the mixed-values case as a separate table-driven row set to verify non-"false"
values remain true. Ensure tests reference the parseDataAttributes and
createElement helpers so behavior stays identical.
- Around line 82-87: Add explicit tests in src/embed/parseConfig.test.ts that
cover malformed JSON inputs for the data attributes handled by
parseDataAttributes (specifically "chapters", "playlist", and "speeds") so the
parser's current behavior is codified and protected from regressions; use the
same test patterns as the existing valid-JSON tests (createElement helper, call
parseDataAttributes) but pass invalid JSON strings and assert the current
observed outcome (either expect the call to throw or expect a safe fallback
value on config.player.track!/config.playlist!/config.speeds), and replicate
this for the other locations referenced in the comment to ensure consistent
behavior across parseDataAttributes and any helper parsing functions.

In `@src/examples/NewFeaturesDemo.stories.tsx`:
- Line 21: Replace the hardcoded duration value used in ProgressBar with the
single source of truth (the demo track's duration) — find the ProgressBar prop
currently set to 596 and change it to use sampleVideo.duration (or the existing
track.duration variable) and do the same for the other occurrence around line
388; ensure the value passed is a number and matches the shape expected by
ProgressBar so both instances reference the track's duration instead of the
literal 596.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 90b62af8-01ae-42a1-b773-2517c261d4b8

📥 Commits

Reviewing files that changed from the base of the PR and between 6f6e843 and bc1ce8c.

📒 Files selected for processing (20)
  • package.json
  • src/components/VideoPlayer/EndScreen/EndScreen.test.tsx
  • src/components/controls/ProgressBar/ProgressBar.test.tsx
  • src/components/controls/VolumeControl/VolumeControl.test.tsx
  • src/components/playlist/PlaylistView/PlaylistView.test.tsx
  • src/components/playlist/TrackItem/TrackItem.test.tsx
  • src/context/AdContext.test.tsx
  • src/context/TrackingContext.test.tsx
  • src/embed/parseConfig.test.ts
  • src/examples/NewFeaturesDemo.stories.tsx
  • src/hooks/useAudio.test.ts
  • src/hooks/useCast.test.ts
  • src/hooks/usePauseAd.ts
  • src/hooks/useRewardedAd.ts
  • src/hooks/useSubtitleParser.test.ts
  • src/hooks/useVideo.test.ts
  • src/index.ts
  • src/services/AdService.test.ts
  • src/services/TrackingService.test.ts
  • src/test/helpers.tsx
✅ Files skipped from review due to trivial changes (3)
  • src/context/AdContext.test.tsx
  • src/hooks/useCast.test.ts
  • src/components/controls/ProgressBar/ProgressBar.test.tsx
🚧 Files skipped from review as they are similar to previous changes (5)
  • package.json
  • src/hooks/useAudio.test.ts
  • src/components/playlist/PlaylistView/PlaylistView.test.tsx
  • src/context/TrackingContext.test.tsx
  • src/components/VideoPlayer/EndScreen/EndScreen.test.tsx

Comment on lines +65 to +74
it('shows NowPlayingIndicator when active and playing', () => {
renderTrackItem({ isActive: true, isPlaying: true });
// The NowPlayingIndicator renders with role="img"
expect(screen.getByRole('img')).toBeInTheDocument();
});

it('does not show NowPlayingIndicator when not active', () => {
renderTrackItem({ isActive: false, isPlaying: false });
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether TrackItem can render additional img-role elements (e.g., artwork),
# which would make generic getByRole('img') ambiguous.
rg -n -C3 'NowPlayingIndicator|<img|role=|aria-label|alt=' \
  src/components/playlist/TrackItem/TrackItem.tsx \
  src/components/controls/NowPlayingIndicator/NowPlayingIndicator.tsx

Repository: sushidev-team/fairu-player

Length of output: 4563


Disambiguate NowPlayingIndicator queries to avoid matching artwork.

At lines 68 and 73, getByRole('img') and queryByRole('img') can match the track artwork <img> element (lines 48–52 of TrackItem.tsx) in addition to the NowPlayingIndicator, causing test brittleness. Narrow the selector to the specific indicator using its accessible name.

Proposed test fix
 it('shows NowPlayingIndicator when active and playing', () => {
   renderTrackItem({ isActive: true, isPlaying: true });
-  // The NowPlayingIndicator renders with role="img"
-  expect(screen.getByRole('img')).toBeInTheDocument();
+  expect(screen.getByRole('img', { name: /now playing/i })).toBeInTheDocument();
 });

 it('does not show NowPlayingIndicator when not active', () => {
   renderTrackItem({ isActive: false, isPlaying: false });
-  expect(screen.queryByRole('img')).not.toBeInTheDocument();
+  expect(
+    screen.queryByRole('img', { name: /now playing/i })
+  ).not.toBeInTheDocument();
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('shows NowPlayingIndicator when active and playing', () => {
renderTrackItem({ isActive: true, isPlaying: true });
// The NowPlayingIndicator renders with role="img"
expect(screen.getByRole('img')).toBeInTheDocument();
});
it('does not show NowPlayingIndicator when not active', () => {
renderTrackItem({ isActive: false, isPlaying: false });
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});
it('shows NowPlayingIndicator when active and playing', () => {
renderTrackItem({ isActive: true, isPlaying: true });
expect(screen.getByRole('img', { name: /now playing/i })).toBeInTheDocument();
});
it('does not show NowPlayingIndicator when not active', () => {
renderTrackItem({ isActive: false, isPlaying: false });
expect(
screen.queryByRole('img', { name: /now playing/i })
).not.toBeInTheDocument();
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/playlist/TrackItem/TrackItem.test.tsx` around lines 65 - 74,
The tests in TrackItem.test.tsx use getByRole('img') / queryByRole('img') which
can match the artwork <img> in TrackItem.tsx as well as the NowPlayingIndicator;
update the assertions to target the NowPlayingIndicator by its accessible name
instead of any img role (e.g., use getByRole('img', { name: /now playing/i })
and queryByRole('img', { name: /now playing/i })) so renderTrackItem(...) still
renders the artwork but the test only checks the specific indicator.

Comment on lines +134 to +139
it('disables chapters when data-chapters is "false"', () => {
const el = createElement({ chapters: 'false' });
const config = parseDataAttributes(el);

expect(config.player.features!.chapters).toBe(false);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add a regression test for src + chapters="false" interaction.

Line 134 currently validates only the feature flag path. With src present, parseDataAttributes also parses chapters as track JSON, so "false" can become a non-array track value and slip through untested.

Proposed test addition
+    it('disables chapter feature without populating track chapters when chapters="false"', () => {
+      const el = createElement({ src: 'audio.mp3', chapters: 'false' });
+      const config = parseDataAttributes(el);
+
+      expect(config.player.features!.chapters).toBe(false);
+      expect(config.player.track!.chapters).toBeUndefined();
+    });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('disables chapters when data-chapters is "false"', () => {
const el = createElement({ chapters: 'false' });
const config = parseDataAttributes(el);
expect(config.player.features!.chapters).toBe(false);
});
it('disables chapters when data-chapters is "false"', () => {
const el = createElement({ chapters: 'false' });
const config = parseDataAttributes(el);
expect(config.player.features!.chapters).toBe(false);
});
it('disables chapter feature without populating track chapters when chapters="false"', () => {
const el = createElement({ src: 'audio.mp3', chapters: 'false' });
const config = parseDataAttributes(el);
expect(config.player.features!.chapters).toBe(false);
expect(config.player.track!.chapters).toBeUndefined();
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/embed/parseConfig.test.ts` around lines 134 - 139, Add a regression test
to cover the interaction where an element has a src and chapters="false": create
an element with createElement({ src: 'some.mp4', chapters: 'false' }), call
parseDataAttributes(el) and assert that config.player.features!.chapters is
false (and that no chapters track array was parsed); this ensures
parseDataAttributes handles the chapters="false" string correctly when src is
present.

Comment on lines +333 to +350
onClick={() => {
setLoopStart(currentTime);
addEvent(`Loop A set: ${Math.floor(currentTime)}s`);
}}
className={`flex-1 px-2 py-1 rounded text-xs border transition-colors ${
loopStart !== null
? 'border-blue-400 text-blue-400'
: 'border-gray-600 text-gray-400'
}`}
>
Set A{' '}
{loopStart !== null ? `(${Math.floor(loopStart)}s)` : ''}
</button>
<button
onClick={() => {
setLoopEnd(currentTime);
addEvent(`Loop B set: ${Math.floor(currentTime)}s`);
}}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against inverted A/B loop bounds before rendering preview.

If users set B before A (or move A past B), loopStart > loopEnd can be sent to ProgressBar, producing an invalid loop region.

💡 Proposed fix
+  const normalizedLoop = useMemo(() => {
+    if (loopStart === null || loopEnd === null) return null;
+    return {
+      start: Math.min(loopStart, loopEnd),
+      end: Math.max(loopStart, loopEnd),
+    };
+  }, [loopStart, loopEnd]);
+
   // Convert subtitle style to CSS
   const subtitleCss = useMemo(
     () => styleToCss(subtitleStyle),
     [subtitleStyle],
   );
@@
-            {loopStart !== null && loopEnd !== null && (
+            {normalizedLoop && (
               <div className="text-xs text-blue-400">
-                Looping: {Math.floor(loopStart)}s &rarr;{' '}
-                {Math.floor(loopEnd)}s
+                Looping: {Math.floor(normalizedLoop.start)}s &rarr;{' '}
+                {Math.floor(normalizedLoop.end)}s
               </div>
             )}
@@
-      {loopStart !== null && loopEnd !== null && (
+      {normalizedLoop && (
         <div className="bg-gray-800 rounded-lg p-4">
@@
           <ProgressBar
             currentTime={currentTime}
             duration={596}
-            loopStart={loopStart}
-            loopEnd={loopEnd}
+            loopStart={normalizedLoop.start}
+            loopEnd={normalizedLoop.end}
             disabled
           />
         </div>
       )}

Also applies to: 370-393

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/examples/NewFeaturesDemo.stories.tsx` around lines 333 - 350, Guard the
loop bounds before passing them to the ProgressBar: ensure loopStart and loopEnd
are non-null and that loopStart <= loopEnd (normalize by swapping or clamping if
inverted) wherever you compute the preview or render ProgressBar (e.g., where
loopStart, loopEnd are used after setLoopStart/setLoopEnd and in the second
block around lines 370-393); update the code that supplies the loop region to
compute safeStart = Math.min(loopStart, loopEnd) and safeEnd =
Math.max(loopStart, loopEnd) (or skip rendering the preview when either is null)
and pass safeStart/safeEnd to ProgressBar to avoid sending an inverted range.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant